Skip to main content

tsift_cli/
lib.rs

1mod cli;
2mod commands;
3mod community_detection;
4mod conflict_matrix;
5mod context_pack;
6mod output;
7mod rewrite;
8mod search_budget;
9mod semantic_edit;
10mod session_review_budget;
11mod token_savings;
12mod workflow;
13
14pub(crate) use community_detection::{
15    CommunityDetectionReport, annotate_community_members_with_context,
16    community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
17    detect_communities_cached, file_communities_from_callers, graph_effectiveness_blocked,
18    graph_effectiveness_ready, resolve_tagpath_handle_for_callee_edge,
19    update_community_annotation_diagnostics,
20};
21#[allow(unused_imports)]
22pub(crate) use conflict_matrix::{
23    ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs, ConflictMatrixPreparedInputs,
24    ConflictMatrixReport, ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
25    ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket, build_conflict_matrix_report,
26    build_conflict_matrix_report_from_prepared_graph, cmd_conflict_matrix,
27    collect_conflict_matrix_evidence_packets, conflict_matrix_candidate_from_evidence,
28    conflict_matrix_graph_index, conflict_matrix_semantic_ref,
29    conflict_matrix_shared_preparation_summary, conflict_matrix_source_handle,
30    conflict_matrix_target_scoped_graph_snapshot, conflict_matrix_worker_feedback,
31    conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex, is_planner_config_path,
32    normalize_conflict_target, prepare_conflict_matrix_graph_orchestration,
33    prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets, sorted_intersection,
34    sorted_set,
35};
36#[allow(unused_imports)]
37pub(crate) use context_pack::{
38    ContextPackReport, ContextPackSummaryRefPreview, build_context_pack_diff_preview,
39    build_context_pack_log_preview, build_context_pack_report,
40    build_context_pack_report_with_profile, build_context_pack_test_preview,
41    context_pack_status_reminders, exploration_ref_id, materialize_context_pack_exploration_packet,
42    print_context_pack_human,
43};
44pub use rewrite::rewrite_command;
45pub(crate) use rewrite::{
46    apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message,
47};
48#[cfg(test)]
49use search_budget::{SearchBudgetReport, search_facet_filters_summary};
50pub(crate) use search_budget::{
51    SearchBudgetReportInput, apply_search_facet_filters, build_search_budget_follow_up,
52    build_search_budget_report, print_search_budget_human,
53};
54pub(crate) use semantic_edit::{
55    AstSpanPreview, EditBatch, EditResult, EditStatus, MarkdownEmbeddedSymbol,
56    MarkdownSpanMetadata, MetricDigestOptions, SemanticEditVerifyOptions,
57    apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
58};
59#[allow(unused_imports)]
60pub(crate) use session_review_budget::{
61    SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
62    SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
63    build_session_review_budget_report, build_session_review_next_context_budget_report,
64    print_session_review_budget_human, print_session_review_next_context_budget_human,
65};
66
67#[cfg(test)]
68use rewrite::{
69    OutputCap, apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path,
70    rewrite_output_cap,
71};
72#[cfg(test)]
73use std::io::{BufRead as _, BufReader};
74#[cfg(test)]
75use token_savings::{
76    TokenSavingsFamily, TokenSavingsFixture, TokenSavingsFixtureCase,
77    TokenSavingsMarkdownProjectionInput, TokenSavingsMarkdownProjectionInputs,
78    TokenSavingsRawSymbol, TokenSavingsSourceReadInput, TokenSavingsSourceReadInputs,
79    build_token_savings_report,
80};
81
82use anyhow::{Context, Result, bail};
83use clap::Parser;
84use cli::{
85    AstGrepCommand, Cli, Commands, DispatchTraceFormat, GraphDbQuery, KgCommand, LeaseCommand,
86    LocalModelCommand,
87    SemanticRelatedKind, SourceReadStyle,
88};
89
90#[cfg(test)]
91use cli::{GraphDbBackend, TraverseFormat};
92use commands::digests::{
93    cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
94    cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
95};
96#[cfg(test)]
97use commands::graph::cmd_explain;
98use commands::graph::{
99    cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
100};
101#[cfg(test)]
102use commands::index_search::cmd_search;
103use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
104use commands::infra::{
105    StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
106    cmd_rewrite, cmd_route, cmd_sql, cmd_status,
107};
108use commands::memory::cmd_memory;
109use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
110use commands::summarize::cmd_summarize;
111use flate2::{Compression, read::GzDecoder, write::GzEncoder};
112#[cfg(test)]
113use output::ResponseBudgetPreset;
114use output::tagpath::{
115    TagpathAnnotationDiagnostic, TagpathSearchOpts, annotate_communities_with_tagpath,
116    annotate_hits_with_tagpath, annotate_path_nodes_with_tagpath,
117    annotate_stored_edges_with_tagpath, annotate_stored_symbols_with_tagpath,
118};
119use output::{
120    OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric, ToolEnvelopeSummary,
121    TranscriptArtifactRef,
122};
123use rusqlite::{Connection, OptionalExtension, Row};
124use serde::{Deserialize, Serialize};
125use sift::{SearchInput, SearchOptions, Sift};
126#[cfg(test)]
127use std::cell::RefCell;
128use std::cmp::Ordering;
129use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
130use std::env;
131use std::fs;
132use std::io::{Read as _, Write as _};
133use std::path::{Path, PathBuf};
134use std::process::{Command, Stdio};
135use std::sync::{Mutex, OnceLock};
136use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
137use substrate::{
138    ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
139    GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
140    GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
141    SqliteGraphStore, SqliteProjectionRefresh, TerseGraphEdge as SubstrateTerseGraphEdge,
142    TerseGraphNode as SubstrateTerseGraphNode,
143};
144use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
145#[cfg(test)]
146use tsift_agent_doc::session_cost;
147use tsift_agent_doc::session_markdown::{self, AgentDocQueueItem, AgentDocSessionDocument};
148#[cfg(test)]
149use tsift_agent_doc::session_review;
150use tsift_cache::cycle_packet_cache;
151use tsift_core::{
152    NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
153};
154use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
155use tsift_graph as graph;
156use tsift_index::{config, index, init, multiplicity, walk};
157use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
158#[cfg(test)]
159use tsift_memory::MemoryEvent;
160use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
161use tsift_resolution as resolution;
162use tsift_search::{impact, sift};
163use tsift_sqlite as substrate;
164use tsift_status::status;
165use tsift_summarize::summarize;
166#[cfg(feature = "backend-surrealdb")]
167use tsift_surrealdb::SurrealdbGraphStore;
168use tsift_tokensave::TokensaveDb;
169
170#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
171pub(crate) enum GraphDbExperimentalBackend {
172    DuckdbDuckpgq,
173    Falkordb,
174    Ladybug,
175    Kuzu,
176    Surrealdb,
177}
178
179#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
180pub(crate) struct SearchFacetFilters {
181    #[serde(skip_serializing_if = "Vec::is_empty", default)]
182    pub(crate) languages: Vec<String>,
183    #[serde(skip_serializing_if = "Vec::is_empty", default)]
184    pub(crate) kinds: Vec<String>,
185    #[serde(skip_serializing_if = "Vec::is_empty", default)]
186    pub(crate) node_kinds: Vec<String>,
187    #[serde(skip_serializing_if = "Vec::is_empty", default)]
188    pub(crate) sections: Vec<String>,
189    #[serde(skip_serializing_if = "Vec::is_empty", default)]
190    pub(crate) parents: Vec<String>,
191    #[serde(skip_serializing_if = "Vec::is_empty", default)]
192    pub(crate) children: Vec<String>,
193    #[serde(skip_serializing_if = "Vec::is_empty", default)]
194    pub(crate) fence_languages: Vec<String>,
195    #[serde(skip_serializing_if = "Vec::is_empty", default)]
196    pub(crate) list_depths: Vec<usize>,
197    #[serde(skip_serializing_if = "Vec::is_empty", default)]
198    pub(crate) heading_levels: Vec<usize>,
199}
200
201impl SearchFacetFilters {
202    pub(crate) fn is_empty(&self) -> bool {
203        self.languages.is_empty()
204            && self.kinds.is_empty()
205            && self.node_kinds.is_empty()
206            && self.sections.is_empty()
207            && self.parents.is_empty()
208            && self.children.is_empty()
209            && self.fence_languages.is_empty()
210            && self.list_depths.is_empty()
211            && self.heading_levels.is_empty()
212    }
213
214    fn needs_ast_context(&self) -> bool {
215        !self.sections.is_empty()
216            || !self.parents.is_empty()
217            || !self.children.is_empty()
218            || !self.fence_languages.is_empty()
219            || !self.list_depths.is_empty()
220            || !self.heading_levels.is_empty()
221    }
222}
223
224#[derive(Serialize)]
225struct GraphDbBackendPromotionGate {
226    status: String,
227    native_adapter_required: bool,
228    required_checks: Vec<String>,
229}
230
231impl GraphDbExperimentalBackend {
232    fn name(self) -> &'static str {
233        match self {
234            Self::DuckdbDuckpgq => "duckdb-duckpgq",
235            Self::Falkordb => "falkordb",
236            Self::Ladybug => "ladybug",
237            Self::Kuzu => "kuzu",
238            Self::Surrealdb => "surrealdb",
239        }
240    }
241
242    fn adapter_label(self) -> &'static str {
243        match self {
244            Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
245            Self::Falkordb => "FalkorDB read-only prototype",
246            Self::Ladybug => "Ladybug read-only prototype",
247            Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
248            Self::Surrealdb => "SurrealDB read-only prototype",
249        }
250    }
251
252    fn projection_load(self) -> &'static str {
253        match self {
254            Self::Falkordb => {
255                "provider-neutral rows loaded into a FalkorDB-shaped read snapshot for parity and timing only; production FalkorDB storage remains behind backend-eval until a real adapter passes the full-projection gate"
256            }
257            Self::Kuzu => {
258                "provider-neutral rows loaded into a Kuzu-compatible in-process read snapshot for parity and performance gates; production Vela-Engineering/kuzu storage remains behind a future optional adapter"
259            }
260            Self::Surrealdb => {
261                "provider-neutral rows loaded into a SurrealDB-compatible read snapshot for parity and timing only; production SurrealDB storage remains behind backend-eval until a real optional adapter passes the full-projection gate"
262            }
263            _ => {
264                "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
265            }
266        }
267    }
268
269    fn lock_behavior(self) -> &'static str {
270        match self {
271            Self::Falkordb => {
272                "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
273            }
274            Self::Kuzu => {
275                "read-only Kuzu prototype snapshot; no SQLite writer lock is taken during benchmarks, and production Vela-Engineering/kuzu promotion must prove concurrent writer semantics before replacing SQLite"
276            }
277            Self::Surrealdb => {
278                "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
279            }
280            _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
281        }
282    }
283
284    fn install_portability(self) -> &'static str {
285        match self {
286            Self::Falkordb => {
287                "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
288            }
289            Self::Kuzu => {
290                "prototype is dependency-free in this binary; production Vela-Engineering/kuzu integration must stay optional so cargo build/install works without a native Kuzu toolchain"
291            }
292            Self::Surrealdb => {
293                "prototype is dependency-free in this binary; production SurrealDB integration must stay optional so cargo build/install works without pulling SurrealDB into the default build"
294            }
295            _ => {
296                "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
297            }
298        }
299    }
300
301    fn prototype_hold_reason(self) -> Option<&'static str> {
302        match self {
303            Self::DuckdbDuckpgq => Some(
304                "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
305            ),
306            Self::Falkordb => Some(
307                "FalkorDB remains behind backend-eval until a production adapter beats SQLite on full_projection conflict-matrix, evidence, dispatch-trace, path tiers, install portability, and lock behavior",
308            ),
309            Self::Ladybug => Some(
310                "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
311            ),
312            Self::Kuzu => Some(
313                "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
314            ),
315            Self::Surrealdb => Some(
316                "SurrealDB remains behind backend-eval until a feature-gated optional adapter proves provider-neutral projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
317            ),
318        }
319    }
320
321    fn promotion_gate(self) -> GraphDbBackendPromotionGate {
322        match self {
323            Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
324                status: "hold_native_adapter_required".to_string(),
325                native_adapter_required: true,
326                required_checks: vec![
327                    "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
328                        .to_string(),
329                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
330                        .to_string(),
331                    "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
332                    "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
333                        .to_string(),
334                ],
335            },
336            Self::Falkordb => GraphDbBackendPromotionGate {
337                status: "hold_native_adapter_required".to_string(),
338                native_adapter_required: true,
339                required_checks: vec![
340                    "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
341                        .to_string(),
342                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
343                        .to_string(),
344                    "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
345                        .to_string(),
346                    "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
347                        .to_string(),
348                ],
349            },
350            Self::Ladybug => GraphDbBackendPromotionGate {
351                status: "hold_native_adapter_required".to_string(),
352                native_adapter_required: true,
353                required_checks: vec![
354                    "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
355                        .to_string(),
356                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
357                        .to_string(),
358                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
359                        .to_string(),
360                    "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
361                        .to_string(),
362                ],
363            },
364            Self::Kuzu => GraphDbBackendPromotionGate {
365                status: "hold_native_adapter_required".to_string(),
366                native_adapter_required: true,
367                required_checks: vec![
368                    "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
369                        .to_string(),
370                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
371                        .to_string(),
372                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
373                        .to_string(),
374                    "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
375                        .to_string(),
376                ],
377            },
378            Self::Surrealdb => GraphDbBackendPromotionGate {
379                status: "hold_native_adapter_required".to_string(),
380                native_adapter_required: true,
381                required_checks: vec![
382                    "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
383                        .to_string(),
384                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
385                        .to_string(),
386                    "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
387                        .to_string(),
388                    "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
389                        .to_string(),
390                ],
391            },
392        }
393    }
394
395    fn parse(raw: &str) -> Result<Self> {
396        match raw {
397            "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
398            "falkordb" | "falkor" => Ok(Self::Falkordb),
399            "ladybug" => Ok(Self::Ladybug),
400            "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
401            "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
402            _ => {
403                bail!(
404                    "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
405                )
406            }
407        }
408    }
409}
410
411pub fn run() -> Result<()> {
412    let cli = Cli::parse();
413    let compact = cli.compact;
414    let pretty = cli.pretty;
415    let terse = cli.terse || cli.ultra_terse;
416    let ultra_terse = cli.ultra_terse;
417    let absolute = cli.absolute;
418    let tabular = cli.tabular;
419    let schema = cli.schema;
420    let envelope = cli.envelope;
421    match cli.command {
422        Some(Commands::Search {
423            query,
424            path,
425            limit,
426            strategy,
427            exact,
428            scope,
429            federated,
430            lang,
431            kind,
432            node_kind,
433            section,
434            parent,
435            child,
436            fence_language,
437            list_depth,
438            heading_level,
439            json,
440            autoindex,
441            no_autoindex,
442            timeout,
443            max_items,
444            max_bytes,
445            budget,
446            no_tagpath,
447            tagpath_strict,
448        }) => cmd_search_with_budget(
449            query,
450            path,
451            limit,
452            if exact {
453                Some("exact".to_string())
454            } else {
455                strategy
456            },
457            scope,
458            federated,
459            json || terse || schema || envelope,
460            autoindex || !no_autoindex,
461            timeout,
462            compact,
463            pretty,
464            terse,
465            ultra_terse,
466            absolute,
467            tabular,
468            schema,
469            envelope,
470            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
471            TagpathSearchOpts {
472                no_tagpath,
473                strict: tagpath_strict,
474            },
475            SearchFacetFilters {
476                languages: lang,
477                kinds: kind,
478                node_kinds: node_kind,
479                sections: section,
480                parents: parent,
481                children: child,
482                fence_languages: fence_language,
483                list_depths: list_depth,
484                heading_levels: heading_level,
485            },
486        ),
487        Some(Commands::SearchWorker {
488            path,
489            cache_dir,
490            query,
491            limit,
492            strategy,
493            output,
494            fts_index_fresh,
495        }) => cmd_search_worker(
496            &path,
497            &cache_dir,
498            &query,
499            limit,
500            &strategy,
501            &output,
502            fts_index_fresh,
503        ),
504        Some(Commands::DigestRunner {
505            kind,
506            path,
507            runner,
508            shell_command,
509            json,
510        }) => cmd_digest_runner(
511            &kind,
512            &path,
513            runner.as_deref(),
514            &shell_command,
515            OutputFormat {
516                json_output: json || terse || schema || envelope,
517                compact,
518                pretty,
519                terse,
520                ultra_terse,
521                schema,
522                envelope,
523            },
524        ),
525        Some(Commands::AstGrep { command }) => match command {
526            AstGrepCommand::Search {
527                pattern,
528                paths,
529                lang,
530                no_ignore,
531                json,
532                max_items,
533                max_bytes,
534                budget,
535            } => commands::astgrep::cmd_ast_grep_search(
536                &pattern,
537                paths,
538                lang.as_deref(),
539                no_ignore,
540                OutputFormat {
541                    json_output: json || terse || schema || envelope,
542                    compact,
543                    pretty,
544                    terse,
545                    ultra_terse,
546                    schema,
547                    envelope,
548                },
549                ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
550            ),
551            AstGrepCommand::Rewrite {
552                pattern,
553                rewrite,
554                paths,
555                lang,
556                no_ignore,
557                apply,
558                json,
559                max_items,
560                max_bytes,
561                budget,
562            } => commands::astgrep::cmd_ast_grep_rewrite(
563                &pattern,
564                &rewrite,
565                paths,
566                lang.as_deref(),
567                no_ignore,
568                apply,
569                OutputFormat {
570                    json_output: json || terse || schema || envelope,
571                    compact,
572                    pretty,
573                    terse,
574                    ultra_terse,
575                    schema,
576                    envelope,
577                },
578                ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
579            ),
580            AstGrepCommand::Languages { json } => {
581                commands::astgrep::cmd_ast_grep_languages(OutputFormat {
582                    json_output: json || terse || schema || envelope,
583                    compact,
584                    pretty,
585                    terse,
586                    ultra_terse,
587                    schema,
588                    envelope,
589                })
590            }
591        },
592        Some(Commands::Edit { dry_run, file }) => {
593            cmd_edit(dry_run, file, compact, pretty, terse, schema)
594        }
595        Some(Commands::EditIntents {
596            path,
597            scope,
598            file,
599            json,
600            apply,
601            verify,
602            verify_command,
603            max_items,
604            max_bytes,
605            budget,
606        }) => cmd_edit_intents(
607            &path,
608            scope.as_deref(),
609            file,
610            apply,
611            SemanticEditVerifyOptions {
612                enabled: verify,
613                command: verify_command.as_deref(),
614            },
615            OutputFormat {
616                json_output: json || terse || schema || envelope,
617                compact,
618                pretty,
619                terse,
620                ultra_terse,
621                schema,
622                envelope,
623            },
624            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
625        ),
626        Some(Commands::Index {
627            path,
628            rebuild,
629            check,
630            exit_code,
631            prune,
632            quiet,
633            workspace,
634            submodule,
635            json,
636        }) => cmd_index(
637            &path,
638            rebuild,
639            check,
640            exit_code,
641            prune,
642            quiet,
643            workspace,
644            submodule.as_deref(),
645            json || terse || schema || envelope,
646            compact,
647            pretty,
648            terse,
649            absolute,
650            schema,
651        ),
652        Some(Commands::Rewrite { command, run }) => cmd_rewrite(
653            &command,
654            run,
655            OutputFormat {
656                json_output: terse || schema || envelope,
657                compact,
658                pretty,
659                terse,
660                ultra_terse,
661                schema,
662                envelope,
663            },
664        ),
665        Some(Commands::Route { task, id }) => cmd_route(&task, id),
666        Some(Commands::Memory { command }) => {
667            let json = command.json_output();
668            cmd_memory(
669                command,
670                OutputFormat {
671                    json_output: json || terse || schema || envelope,
672                    compact,
673                    pretty,
674                    terse,
675                    ultra_terse,
676                    schema,
677                    envelope,
678                },
679            )
680        }
681        Some(Commands::LocalModel { command }) => {
682            let json = command.json_output();
683            cmd_local_model(
684                command,
685                OutputFormat {
686                    json_output: json || terse || schema || envelope,
687                    compact,
688                    pretty,
689                    terse,
690                    ultra_terse,
691                    schema,
692                    envelope,
693                },
694            )
695        }
696        Some(Commands::Kg { command }) => match command {
697            KgCommand::Extract {
698                profile,
699                model,
700                host,
701                input,
702                source_ref,
703                graph_db,
704                no_lease,
705                idle_ttl_seconds,
706                keep_loaded,
707                lease_file,
708                no_context,
709                json,
710            } => commands::kg::cmd_kg_extract(commands::kg::KgExtractArgs {
711                profile,
712                model,
713                host,
714                input,
715                source_ref,
716                graph_db,
717                no_lease,
718                idle_ttl_seconds,
719                keep_loaded,
720                lease_file,
721                no_context,
722                json: json || terse || schema || envelope,
723            }),
724            KgCommand::Status { graph_db, json } => {
725                commands::kg::cmd_kg_status(graph_db, json || terse || schema || envelope)
726            }
727            KgCommand::Refresh {
728                graph_db,
729                apply,
730                profile,
731                model,
732                host,
733                no_lease,
734                idle_ttl_seconds,
735                keep_loaded,
736                lease_file,
737                no_context,
738                json,
739            } => commands::kg::cmd_kg_refresh(commands::kg::KgRefreshArgs {
740                graph_db,
741                json: json || terse || schema || envelope,
742                apply,
743                profile,
744                model,
745                host,
746                no_lease,
747                idle_ttl_seconds,
748                keep_loaded,
749                lease_file,
750                no_context,
751            }),
752            KgCommand::Evidence {
753                symbol,
754                kind,
755                limit,
756                graph_db,
757                json,
758            } => commands::kg::cmd_kg_evidence(
759                symbol,
760                kind,
761                limit,
762                graph_db,
763                json || terse || schema || envelope,
764            ),
765            KgCommand::Unload {
766                profile,
767                model,
768                host,
769                json,
770            } => commands::kg::cmd_kg_unload(
771                profile,
772                model,
773                host,
774                json || terse || schema || envelope,
775            ),
776            KgCommand::Smoke {
777                profile,
778                model,
779                host,
780                unload,
781                json,
782            } => commands::kg::cmd_kg_smoke(
783                profile,
784                model,
785                host,
786                unload,
787                json || terse || schema || envelope,
788            ),
789        },
790        Some(Commands::Finding { command }) => match command {
791            cli::FindingCommand::Add {
792                path,
793                kind,
794                title,
795                body,
796                about,
797                confidence,
798                status,
799                relates,
800                scope,
801                json,
802            } => commands::finding::cmd_finding_add(
803                &path,
804                &kind,
805                &title,
806                &body,
807                &about,
808                confidence,
809                &status,
810                relates.as_deref(),
811                scope.as_deref(),
812                json || terse || schema || envelope,
813                pretty,
814            ),
815            cli::FindingCommand::List {
816                path,
817                about,
818                kind,
819                status,
820                include_stale,
821                scope,
822                json,
823            } => commands::finding::cmd_finding_list(
824                &path,
825                about.as_deref(),
826                kind.as_deref(),
827                status.as_deref(),
828                include_stale,
829                scope.as_deref(),
830                json || terse || schema || envelope,
831                pretty,
832            ),
833            cli::FindingCommand::Harvest { path, scope, json } => {
834                commands::finding::cmd_finding_harvest(
835                    &path,
836                    scope.as_deref(),
837                    json || terse || schema || envelope,
838                    pretty,
839                )
840            }
841            cli::FindingCommand::Promote { id, path, json } => {
842                commands::finding::cmd_finding_promote(
843                    &path,
844                    &id,
845                    json || terse || schema || envelope,
846                    pretty,
847                )
848            }
849        },
850        Some(Commands::Graph {
851            symbol,
852            path,
853            callers,
854            callees,
855            scope,
856            limit,
857            json,
858            no_tagpath,
859            tagpath_strict,
860        }) => cmd_graph(
861            &symbol,
862            &path,
863            callers,
864            callees,
865            scope.as_deref(),
866            limit,
867            json || terse || schema || envelope,
868            compact,
869            pretty,
870            terse,
871            absolute,
872            tabular,
873            schema,
874            TagpathSearchOpts {
875                no_tagpath,
876                strict: tagpath_strict,
877            },
878        ),
879        Some(Commands::Sql {
880            db,
881            query,
882            table,
883            json,
884        }) => cmd_sql(
885            &db,
886            query,
887            table,
888            json || terse || schema || envelope,
889            compact,
890            pretty,
891            terse,
892            schema,
893        ),
894        Some(Commands::Communities {
895            path,
896            scope,
897            min_size,
898            limit,
899            json,
900            no_tagpath,
901            tagpath_strict,
902        }) => cmd_communities(
903            &path,
904            scope.as_deref(),
905            min_size,
906            limit,
907            json || terse || schema || envelope,
908            compact,
909            pretty,
910            terse,
911            tabular,
912            schema,
913            TagpathSearchOpts {
914                no_tagpath,
915                strict: tagpath_strict,
916            },
917        ),
918        Some(Commands::Analyze {
919            path,
920            scope,
921            entry_points,
922            limit,
923            json,
924        }) => cmd_analyze(
925            &path,
926            scope.as_deref(),
927            &entry_points,
928            limit,
929            OutputFormat {
930                json_output: json || terse || schema || envelope,
931                compact,
932                pretty,
933                terse,
934                ultra_terse,
935                schema,
936                envelope,
937            },
938        ),
939        Some(Commands::Path {
940            from,
941            to,
942            path,
943            scope,
944            json,
945            no_tagpath,
946            tagpath_strict,
947        }) => cmd_path(
948            &from,
949            &to,
950            &path,
951            scope.as_deref(),
952            json || terse || schema || envelope,
953            compact,
954            pretty,
955            terse,
956            schema,
957            TagpathSearchOpts {
958                no_tagpath,
959                strict: tagpath_strict,
960            },
961        ),
962        Some(Commands::Explain {
963            symbol,
964            path,
965            scope,
966            limit,
967            json,
968            max_items,
969            max_bytes,
970            budget,
971            no_tagpath,
972            tagpath_strict,
973        }) => cmd_explain_with_budget(
974            &symbol,
975            &path,
976            scope.as_deref(),
977            limit,
978            json || terse || schema || envelope,
979            compact,
980            pretty,
981            terse,
982            ultra_terse,
983            absolute,
984            tabular,
985            schema,
986            envelope,
987            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
988            TagpathSearchOpts {
989                no_tagpath,
990                strict: tagpath_strict,
991            },
992        ),
993        Some(Commands::Traverse {
994            node,
995            to,
996            path,
997            scope,
998            depth,
999            limit,
1000            format,
1001            convex_snapshot,
1002        }) => cmd_traverse(
1003            node.as_deref(),
1004            to.as_deref(),
1005            &path,
1006            scope.as_deref(),
1007            depth,
1008            limit,
1009            format,
1010            pretty,
1011            terse,
1012            schema,
1013            convex_snapshot.as_deref(),
1014        ),
1015        Some(Commands::ConvexSync {
1016            path,
1017            scope,
1018            snapshot,
1019            chunk_size,
1020            remote_snapshot,
1021            apply,
1022            endpoint,
1023            auth_token_env,
1024            json,
1025        }) => cmd_convex_sync(
1026            ConvexSyncOptions {
1027                path: &path,
1028                scope: scope.as_deref(),
1029                snapshot: snapshot.as_deref(),
1030                chunk_size,
1031                remote_snapshot,
1032                apply,
1033                endpoint: endpoint.as_deref(),
1034                auth_token_env: &auth_token_env,
1035            },
1036            OutputFormat {
1037                json_output: json || terse || schema || envelope,
1038                compact,
1039                pretty,
1040                terse,
1041                ultra_terse,
1042                schema,
1043                envelope,
1044            },
1045        ),
1046        Some(Commands::GraphDb {
1047            path,
1048            scope,
1049            backend,
1050            convex_snapshot,
1051            json,
1052            query,
1053        }) => cmd_graph_db(
1054            &path,
1055            scope.as_deref(),
1056            backend,
1057            convex_snapshot.as_deref(),
1058            query,
1059            OutputFormat {
1060                json_output: json || terse || schema || envelope,
1061                compact,
1062                pretty,
1063                terse,
1064                ultra_terse,
1065                schema,
1066                envelope,
1067            },
1068        ),
1069        Some(Commands::SourceRead {
1070            file,
1071            path,
1072            style,
1073            start,
1074            lines,
1075            end,
1076            scope,
1077            json,
1078            max_items,
1079            max_bytes,
1080            budget,
1081        }) => cmd_source_read(
1082            &file,
1083            &path,
1084            style,
1085            start,
1086            lines,
1087            end,
1088            scope.as_deref(),
1089            OutputFormat {
1090                json_output: json || terse || schema || envelope,
1091                compact,
1092                pretty,
1093                terse,
1094                ultra_terse,
1095                schema,
1096                envelope,
1097            },
1098            absolute,
1099            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1100        ),
1101        Some(Commands::MarkdownAst {
1102            file,
1103            path,
1104            node,
1105            json,
1106            max_items,
1107            max_bytes,
1108            budget,
1109        }) => cmd_markdown_ast(
1110            &file,
1111            &path,
1112            node.as_deref(),
1113            OutputFormat {
1114                json_output: json || terse || schema || envelope,
1115                compact,
1116                pretty,
1117                terse,
1118                ultra_terse,
1119                schema,
1120                envelope,
1121            },
1122            absolute,
1123            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1124        ),
1125        Some(Commands::SymbolRead {
1126            symbol,
1127            file,
1128            path,
1129            scope,
1130            json,
1131            max_items,
1132            max_bytes,
1133            budget,
1134        }) => cmd_symbol_read(
1135            &symbol,
1136            file.as_deref(),
1137            &path,
1138            scope.as_deref(),
1139            OutputFormat {
1140                json_output: json || terse || schema || envelope,
1141                compact,
1142                pretty,
1143                terse,
1144                ultra_terse,
1145                schema,
1146                envelope,
1147            },
1148            absolute,
1149            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1150        ),
1151        Some(Commands::Audit {
1152            skills_dir,
1153            manifest,
1154            usage,
1155            cleanup,
1156            report,
1157            json,
1158        }) => cmd_audit(
1159            &skills_dir,
1160            manifest,
1161            usage,
1162            cleanup,
1163            report,
1164            json || terse || schema || envelope,
1165            compact,
1166            pretty,
1167            terse,
1168            schema,
1169        ),
1170        Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
1171            &path,
1172            scope.as_deref(),
1173            json || terse || schema || envelope,
1174            pretty,
1175            terse,
1176            schema,
1177        ),
1178        Some(Commands::Init {
1179            path,
1180            codex,
1181            opencode,
1182            workspace,
1183        }) => cmd_init(&path, codex, opencode, workspace),
1184        Some(Commands::Lint {
1185            file,
1186            index,
1187            entities_from,
1188            json,
1189        }) => cmd_lint(
1190            &file,
1191            index,
1192            entities_from,
1193            json || terse || schema || envelope,
1194            compact,
1195            pretty,
1196            terse,
1197            schema,
1198        ),
1199        Some(Commands::Summarize {
1200            symbol,
1201            file,
1202            extract,
1203            diff,
1204            stats,
1205            path,
1206            profile,
1207            json,
1208        }) => cmd_summarize(
1209            symbol,
1210            file,
1211            extract,
1212            diff,
1213            stats,
1214            &path,
1215            json || terse || schema || envelope,
1216            compact,
1217            pretty,
1218            terse,
1219            schema,
1220            profile,
1221        ),
1222        Some(Commands::Semantic {
1223            query,
1224            path,
1225            scope,
1226            limit,
1227            kind,
1228            profile,
1229            json,
1230        }) => cmd_semantic_related(
1231            &query,
1232            &path,
1233            scope.as_deref(),
1234            limit,
1235            kind,
1236            json || terse || schema || envelope,
1237            compact,
1238            pretty,
1239            terse,
1240            schema,
1241            profile,
1242        ),
1243        Some(Commands::DiffDigest {
1244            path,
1245            cached,
1246            revision,
1247            max_parsed_files,
1248            json,
1249        }) => cmd_diff_digest(
1250            &path,
1251            cached,
1252            revision.as_deref(),
1253            max_parsed_files,
1254            OutputFormat {
1255                json_output: json || terse || schema || envelope,
1256                compact,
1257                pretty,
1258                terse,
1259                ultra_terse,
1260                schema,
1261                envelope,
1262            },
1263        ),
1264        Some(Commands::Impact {
1265            path,
1266            cached,
1267            revision,
1268            scope,
1269            limit,
1270            json,
1271        }) => cmd_impact(
1272            &path,
1273            cached,
1274            revision.as_deref(),
1275            scope.as_deref(),
1276            limit,
1277            OutputFormat {
1278                json_output: json || terse || schema || envelope,
1279                compact,
1280                pretty,
1281                terse,
1282                ultra_terse,
1283                schema,
1284                envelope,
1285            },
1286        ),
1287        Some(Commands::TestDigest {
1288            path,
1289            input,
1290            runner,
1291            json,
1292        }) => cmd_test_digest(
1293            &path,
1294            input.as_deref(),
1295            runner.as_deref(),
1296            OutputFormat {
1297                json_output: json || terse || schema || envelope,
1298                compact,
1299                pretty,
1300                terse,
1301                ultra_terse,
1302                schema,
1303                envelope,
1304            },
1305        ),
1306        Some(Commands::LogDigest {
1307            path,
1308            input,
1309            fixture,
1310            fail_under,
1311            json,
1312        }) => cmd_log_digest(
1313            &path,
1314            input.as_deref(),
1315            fixture.as_deref(),
1316            fail_under,
1317            OutputFormat {
1318                json_output: json || terse || schema || envelope,
1319                compact,
1320                pretty,
1321                terse,
1322                ultra_terse,
1323                schema,
1324                envelope,
1325            },
1326        ),
1327        Some(Commands::ContextPack {
1328            path,
1329            test_input,
1330            runner,
1331            log_input,
1332            json,
1333            max_items,
1334            max_bytes,
1335            budget,
1336            convex_snapshot,
1337        }) => cmd_context_pack(
1338            &path,
1339            test_input.as_deref(),
1340            runner.as_deref(),
1341            log_input.as_deref(),
1342            OutputFormat {
1343                json_output: json || terse || schema || envelope,
1344                compact,
1345                pretty,
1346                terse,
1347                ultra_terse,
1348                schema,
1349                envelope,
1350            },
1351            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1352            convex_snapshot.as_deref(),
1353        ),
1354        Some(Commands::ConflictMatrix {
1355            targets,
1356            path,
1357            scope,
1358            depth,
1359            limit,
1360            impact_limit,
1361            json,
1362        }) => cmd_conflict_matrix(
1363            &path,
1364            scope.as_deref(),
1365            &targets,
1366            depth,
1367            limit,
1368            impact_limit,
1369            OutputFormat {
1370                json_output: json || terse || schema || envelope,
1371                compact,
1372                pretty,
1373                terse,
1374                ultra_terse,
1375                schema,
1376                envelope,
1377            },
1378        ),
1379        Some(Commands::DispatchTrace {
1380            targets,
1381            path,
1382            scope,
1383            depth,
1384            limit,
1385            impact_limit,
1386            format,
1387            json,
1388        }) => cmd_dispatch_trace(
1389            DispatchTraceOptions {
1390                path: &path,
1391                scope: scope.as_deref(),
1392                raw_targets: &targets,
1393                depth,
1394                limit,
1395                impact_limit,
1396                trace_format: if json {
1397                    DispatchTraceFormat::Json
1398                } else {
1399                    format
1400                },
1401            },
1402            OutputFormat {
1403                json_output: json || terse || schema || envelope,
1404                compact,
1405                pretty,
1406                terse,
1407                ultra_terse,
1408                schema,
1409                envelope,
1410            },
1411        ),
1412        Some(Commands::DependencyDag {
1413            targets,
1414            path,
1415            scope,
1416            depth,
1417            limit,
1418            json,
1419        }) => cmd_dependency_dag(
1420            &path,
1421            scope.as_deref(),
1422            &targets,
1423            depth,
1424            limit,
1425            OutputFormat {
1426                json_output: json || terse || schema || envelope,
1427                compact,
1428                pretty,
1429                terse,
1430                ultra_terse,
1431                schema,
1432                envelope,
1433            },
1434        ),
1435        Some(Commands::TokenSavings {
1436            fixture,
1437            fail_under,
1438            json,
1439        }) => token_savings::cmd_token_savings(
1440            &fixture,
1441            fail_under,
1442            OutputFormat {
1443                json_output: json || terse || schema || envelope,
1444                compact,
1445                pretty,
1446                terse,
1447                ultra_terse,
1448                schema,
1449                envelope,
1450            },
1451        ),
1452        Some(Commands::MetricDigest {
1453            input,
1454            baseline,
1455            metrics,
1456            lower_is_better,
1457            higher_is_better,
1458            history,
1459            top,
1460            json,
1461        }) => cmd_metric_digest(
1462            MetricDigestOptions {
1463                input_path: input.as_deref(),
1464                baseline_path: baseline.as_deref(),
1465                metrics: &metrics,
1466                lower_is_better: &lower_is_better,
1467                higher_is_better: &higher_is_better,
1468                history,
1469                top,
1470            },
1471            OutputFormat {
1472                json_output: json || terse || schema || envelope,
1473                compact,
1474                pretty,
1475                terse,
1476                ultra_terse,
1477                schema,
1478                envelope,
1479            },
1480        ),
1481        Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1482            &fixture,
1483            OutputFormat {
1484                json_output: json || terse || schema || envelope,
1485                compact,
1486                pretty,
1487                terse,
1488                ultra_terse,
1489                schema,
1490                envelope,
1491            },
1492        ),
1493        Some(Commands::TokenGate { command }) => {
1494            cmd_token_gate(
1495                command,
1496                OutputFormat {
1497                    json_output: true,
1498                    compact,
1499                    pretty,
1500                    terse,
1501                    ultra_terse,
1502                    schema,
1503                    envelope,
1504                },
1505            )?;
1506            Ok(())
1507        }
1508        Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1509            &topic,
1510            OutputFormat {
1511                json_output: json || terse || schema || envelope,
1512                compact,
1513                pretty,
1514                terse,
1515                ultra_terse,
1516                schema,
1517                envelope,
1518            },
1519        ),
1520        Some(Commands::SessionDigest {
1521            path,
1522            input,
1523            source,
1524            json,
1525        }) => cmd_session_digest(
1526            &path,
1527            input.as_deref(),
1528            source.as_deref(),
1529            OutputFormat {
1530                json_output: json || terse || schema || envelope,
1531                compact,
1532                pretty,
1533                terse,
1534                ultra_terse,
1535                schema,
1536                envelope,
1537            },
1538        ),
1539        Some(Commands::SessionCost {
1540            input,
1541            fixture,
1542            fail_under,
1543            source,
1544            json,
1545        }) => cmd_session_cost(
1546            input.as_deref(),
1547            fixture.as_deref(),
1548            fail_under,
1549            source.as_deref(),
1550            OutputFormat {
1551                json_output: json || terse || schema || envelope,
1552                compact,
1553                pretty,
1554                terse,
1555                ultra_terse,
1556                schema,
1557                envelope,
1558            },
1559        ),
1560        Some(Commands::SessionReview {
1561            path,
1562            next_context,
1563            json,
1564            max_items,
1565            max_bytes,
1566            budget,
1567        }) => cmd_session_review_with_budget(
1568            &path,
1569            next_context,
1570            OutputFormat {
1571                json_output: json || terse || schema || envelope,
1572                compact,
1573                pretty,
1574                terse,
1575                ultra_terse,
1576                schema,
1577                envelope,
1578            },
1579            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1580        ),
1581        Some(Commands::Status {
1582            path,
1583            fix,
1584            no_fix,
1585            json,
1586        }) => cmd_status(
1587            &path,
1588            StatusCommandOptions {
1589                fix,
1590                no_fix,
1591                json_output: json || terse || schema || envelope,
1592                compact,
1593                pretty,
1594                terse,
1595                schema,
1596            },
1597        ),
1598        Some(Commands::Locks { path, scope, json }) => cmd_locks(
1599            &path,
1600            scope.as_deref(),
1601            json || terse || schema || envelope,
1602            compact,
1603            pretty,
1604            terse,
1605            schema,
1606        ),
1607        None => {
1608            println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1609            println!("Run `tsift --help` for usage.");
1610            Ok(())
1611        }
1612    }
1613}
1614
1615fn cmd_local_model(command: LocalModelCommand, output: OutputFormat) -> Result<()> {
1616    match command {
1617        LocalModelCommand::Status { no_probe, .. } => {
1618            let report = tsift_local_model::build_status_report(!no_probe);
1619            if output.json_output {
1620                if output.pretty {
1621                    println!("{}", serde_json::to_string_pretty(&report)?);
1622                } else {
1623                    println!("{}", serde_json::to_string(&report)?);
1624                }
1625            } else {
1626                print!("{}", tsift_local_model::format_status_human(&report));
1627            }
1628            Ok(())
1629        }
1630        LocalModelCommand::Unload {
1631            profile,
1632            provider_endpoint,
1633            provider_pid,
1634            idle_ttl_seconds,
1635            no_probe,
1636            pre_used_mib,
1637            post_used_mib,
1638            tolerance_mib,
1639            strict,
1640            ..
1641        } => {
1642            let profile = tsift_local_model::profile_by_id(&profile)
1643                .with_context(|| format!("unknown local model profile {profile:?}"))?;
1644            let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1645            let post_probe =
1646                lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1647            let report = tsift_local_model::build_lifecycle_report(
1648                profile,
1649                pre_probe,
1650                post_probe,
1651                provider_endpoint,
1652                provider_pid,
1653                idle_ttl_seconds,
1654                tolerance_mib,
1655            );
1656            if output.json_output {
1657                if output.pretty {
1658                    println!("{}", serde_json::to_string_pretty(&report)?);
1659                } else {
1660                    println!("{}", serde_json::to_string(&report)?);
1661                }
1662            } else {
1663                print!("{}", tsift_local_model::format_lifecycle_human(&report));
1664            }
1665            if strict && !report.cleanup.cleanup_proven {
1666                bail!(
1667                    "local model VRAM cleanup was not proven: {}",
1668                    report.cleanup.reason
1669                );
1670            }
1671            Ok(())
1672        }
1673        LocalModelCommand::Lease { command } => cmd_local_model_lease(command, output),
1674        LocalModelCommand::Resolve {
1675            profile,
1676            role,
1677            no_probe,
1678            ..
1679        } => {
1680            let preference_value = profile.as_deref();
1681            let preference = tsift_local_model::ProfilePreference::from_cli(preference_value);
1682            let probe = if no_probe {
1683                tsift_local_model::GpuProbe::unavailable("gpu probe skipped")
1684            } else {
1685                tsift_local_model::probe_nvidia_smi()
1686            };
1687            let resolution = tsift_local_model::resolve_profile_preference(
1688                &preference,
1689                role.to_model_role(),
1690                &probe,
1691            );
1692            if output.json_output {
1693                if output.pretty {
1694                    println!("{}", serde_json::to_string_pretty(&resolution)?);
1695                } else {
1696                    println!("{}", serde_json::to_string(&resolution)?);
1697                }
1698            } else {
1699                println!(
1700                    "preference: {} | role: {:?}",
1701                    preference.describe(),
1702                    role.to_model_role()
1703                );
1704                println!(
1705                    "selected: {} ({})",
1706                    resolution.profile.id, resolution.profile.label
1707                );
1708                println!("selectable: {}", resolution.selectable);
1709                println!("source: {:?}", resolution.source);
1710                println!("reason: {}", resolution.reason);
1711            }
1712            Ok(())
1713        }
1714        LocalModelCommand::Swap {
1715            from,
1716            to,
1717            provider_endpoint,
1718            provider_pid,
1719            idle_ttl_seconds,
1720            no_probe,
1721            pre_used_mib,
1722            post_used_mib,
1723            tolerance_mib,
1724            strict,
1725            ..
1726        } => {
1727            let from_profile = tsift_local_model::profile_by_id(&from)
1728                .with_context(|| format!("unknown source local model profile {from:?}"))?;
1729            let to_profile = tsift_local_model::profile_by_id(&to)
1730                .with_context(|| format!("unknown target local model profile {to:?}"))?;
1731            let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1732            let post_probe =
1733                lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1734            let report = tsift_local_model::build_swap_report(
1735                from_profile,
1736                to_profile,
1737                pre_probe,
1738                post_probe,
1739                provider_endpoint,
1740                provider_pid,
1741                idle_ttl_seconds,
1742                tolerance_mib,
1743            );
1744            if output.json_output {
1745                if output.pretty {
1746                    println!("{}", serde_json::to_string_pretty(&report)?);
1747                } else {
1748                    println!("{}", serde_json::to_string(&report)?);
1749                }
1750            } else {
1751                println!(
1752                    "swap: {} -> {} | status: {:?}",
1753                    report.from_profile_id, report.to_profile_id, report.swap_status
1754                );
1755                println!(
1756                    "unload cleanup: {:?} ({})",
1757                    report.unload.cleanup.status, report.unload.cleanup.reason
1758                );
1759                println!(
1760                    "target resolution: {:?} -> {} (selectable: {})",
1761                    report.target_resolution.source,
1762                    report.target_resolution.profile.id,
1763                    report.target_resolution.selectable
1764                );
1765                for note in &report.notes {
1766                    println!("note: {note}");
1767                }
1768            }
1769            if strict {
1770                match report.swap_status {
1771                    tsift_local_model::SwapStatus::UnloadNotProven => {
1772                        bail!(
1773                            "swap blocked: source unload cleanup was not proven ({})",
1774                            report.unload.cleanup.reason
1775                        );
1776                    }
1777                    tsift_local_model::SwapStatus::UnloadProvenTargetUnselectable => {
1778                        bail!(
1779                            "swap blocked: target {} is not selectable on the post-unload probe",
1780                            report.to_profile_id
1781                        );
1782                    }
1783                    _ => {}
1784                }
1785            }
1786            Ok(())
1787        }
1788    }
1789}
1790
1791/// Resolve a profile's Ollama model tag and POST a `keep_alive:0` unload.
1792///
1793/// Used by the reference-counted lease paths (`release --unload-on-last-release`,
1794/// `reap --unload-empty`) once a profile's live holder count reaches zero. An
1795/// unknown profile or unreachable provider degrades to a non-fatal
1796/// `UnloadActionResult` so reaping never fails on unload.
1797fn unload_profile_model(
1798    profile_id: &str,
1799    host: Option<&str>,
1800) -> tsift_local_model::UnloadActionResult {
1801    let endpoint = tsift_local_model::resolve_provider_endpoint(
1802        &tsift_local_model::UnloadStrategy::OllamaKeepAliveZero,
1803        host,
1804    );
1805    match tsift_local_model::profile_by_id(profile_id) {
1806        Some(profile) => tsift_local_model::unload_model_at(&endpoint, profile.model_ref),
1807        None => tsift_local_model::unload_model_at(&endpoint, profile_id),
1808    }
1809}
1810
1811fn cmd_local_model_lease(command: LeaseCommand, output: OutputFormat) -> Result<()> {
1812    use tsift_local_model::{
1813        acquire_lease, current_unix_seconds, format_lease_show_human, lease_mode_for_profile,
1814        profile_by_id, reap_leases, release_lease, renew_lease, resolve_lease_file, show_registry,
1815    };
1816    let now = current_unix_seconds();
1817    match command {
1818        LeaseCommand::Acquire {
1819            profile,
1820            holder_pid,
1821            holder_command,
1822            idle_ttl_seconds,
1823            vram_baseline_mib,
1824            no_probe,
1825            lease_file,
1826            strict,
1827            ..
1828        } => {
1829            let profile_lookup = profile_by_id(&profile)
1830                .with_context(|| format!("unknown local model profile {profile:?}"))?;
1831            // CpuOrHash profiles bypass the registry; confirm that upfront.
1832            let _ = lease_mode_for_profile(&profile_lookup);
1833            let pid = holder_pid.unwrap_or_else(std::process::id);
1834            let baseline = match vram_baseline_mib {
1835                Some(value) => value,
1836                None => {
1837                    if no_probe {
1838                        0
1839                    } else {
1840                        let probe = tsift_local_model::probe_nvidia_smi();
1841                        probe.used_vram_mib.unwrap_or(0)
1842                    }
1843                }
1844            };
1845            let path = resolve_lease_file(lease_file.as_deref());
1846            let acquisition = acquire_lease(
1847                &profile,
1848                pid,
1849                &holder_command,
1850                baseline,
1851                idle_ttl_seconds,
1852                now,
1853                &path,
1854            )?;
1855            if output.json_output {
1856                if output.pretty {
1857                    println!("{}", serde_json::to_string_pretty(&acquisition)?);
1858                } else {
1859                    println!("{}", serde_json::to_string(&acquisition)?);
1860                }
1861            } else {
1862                println!(
1863                    "lease {} for {} (pid={}): {:?}",
1864                    match acquisition.status {
1865                        tsift_local_model::GpuLeaseAcquisitionStatus::Acquired => "acquired",
1866                        tsift_local_model::GpuLeaseAcquisitionStatus::Refreshed => "refreshed",
1867                        tsift_local_model::GpuLeaseAcquisitionStatus::ReclaimedStale => {
1868                            "reclaimed-stale"
1869                        }
1870                        tsift_local_model::GpuLeaseAcquisitionStatus::CpuOrHashBypass => {
1871                            "bypass-cpu-or-hash"
1872                        }
1873                        tsift_local_model::GpuLeaseAcquisitionStatus::Conflict => "conflicted",
1874                    },
1875                    acquisition.profile_id,
1876                    acquisition.holder_pid,
1877                    acquisition.status
1878                );
1879                if let Some(conflict) = &acquisition.conflict {
1880                    println!(
1881                        "held by pid={} cmd={} acquired {}s ago",
1882                        conflict.holder_pid,
1883                        conflict.holder_command,
1884                        now.saturating_sub(conflict.acquired_at_unix_seconds)
1885                    );
1886                }
1887                println!("registry: {}", path.display());
1888            }
1889            if strict
1890                && acquisition.status == tsift_local_model::GpuLeaseAcquisitionStatus::Conflict
1891            {
1892                bail!(
1893                    "gpu lease for {profile:?} is held by pid={}",
1894                    acquisition
1895                        .conflict
1896                        .map(|conflict| conflict.holder_pid.to_string())
1897                        .unwrap_or_else(|| "unknown".to_string())
1898                );
1899            }
1900            Ok(())
1901        }
1902        LeaseCommand::Release {
1903            profile,
1904            holder_pid,
1905            lease_file,
1906            unload_on_last_release,
1907            host,
1908            ..
1909        } => {
1910            let pid = holder_pid.unwrap_or_else(std::process::id);
1911            let path = resolve_lease_file(lease_file.as_deref());
1912            let release = release_lease(&profile, pid, now, &path)?;
1913            // Reference-counted unload: when this release drops the live holder
1914            // count to zero, the model is no longer referenced by any session.
1915            let unloaded = if unload_on_last_release
1916                && release.outcome == tsift_local_model::GpuLeaseReleaseOutcome::Released
1917                && release.remaining_holders == 0
1918            {
1919                Some(unload_profile_model(&profile, host.as_deref()))
1920            } else {
1921                None
1922            };
1923            if output.json_output {
1924                let payload = serde_json::json!({
1925                    "release": release,
1926                    "unloaded": unloaded,
1927                });
1928                if output.pretty {
1929                    println!("{}", serde_json::to_string_pretty(&payload)?);
1930                } else {
1931                    println!("{}", serde_json::to_string(&payload)?);
1932                }
1933            } else {
1934                println!(
1935                    "release {} for {} (pid={}): {:?} (remaining holders: {})",
1936                    match release.outcome {
1937                        tsift_local_model::GpuLeaseReleaseOutcome::Released => "ok",
1938                        tsift_local_model::GpuLeaseReleaseOutcome::NotHeld => "not-held",
1939                        tsift_local_model::GpuLeaseReleaseOutcome::ProfileAbsent => "absent",
1940                    },
1941                    release.profile_id,
1942                    release.holder_pid,
1943                    release.outcome,
1944                    release.remaining_holders
1945                );
1946                if let Some(result) = &unloaded {
1947                    println!("unloaded {} (last reference released): {}", profile, result.outcome);
1948                }
1949                println!("registry: {}", path.display());
1950            }
1951            Ok(())
1952        }
1953        LeaseCommand::Renew {
1954            profile,
1955            holder_pid,
1956            lease_file,
1957            ..
1958        } => {
1959            let pid = holder_pid.unwrap_or_else(std::process::id);
1960            let path = resolve_lease_file(lease_file.as_deref());
1961            let renew = renew_lease(&profile, pid, now, &path)?;
1962            if output.json_output {
1963                if output.pretty {
1964                    println!("{}", serde_json::to_string_pretty(&renew)?);
1965                } else {
1966                    println!("{}", serde_json::to_string(&renew)?);
1967                }
1968            } else {
1969                println!(
1970                    "renew {} (pid={}): {:?}",
1971                    renew.profile_id, renew.holder_pid, renew.outcome
1972                );
1973                println!("registry: {}", path.display());
1974            }
1975            Ok(())
1976        }
1977        LeaseCommand::Reap {
1978            lease_file,
1979            unload_empty,
1980            host,
1981            ..
1982        } => {
1983            let path = resolve_lease_file(lease_file.as_deref());
1984            let reap = reap_leases(now, &path)?;
1985            // Reference-counted unload for every profile whose last reference
1986            // was reclaimed (a crashed session left a dead-pid holder).
1987            let unloaded: Vec<_> = if unload_empty {
1988                reap.emptied_profiles
1989                    .iter()
1990                    .filter_map(|profile_id| {
1991                        profile_by_id(profile_id).map(|profile| {
1992                            serde_json::json!({
1993                                "profile": profile_id,
1994                                "outcome": unload_profile_model(profile_id, host.as_deref()).outcome,
1995                                "model": profile.model_ref,
1996                            })
1997                        })
1998                    })
1999                    .collect()
2000            } else {
2001                Vec::new()
2002            };
2003            if output.json_output {
2004                let payload = serde_json::json!({
2005                    "reap": reap,
2006                    "unloaded": unloaded,
2007                });
2008                if output.pretty {
2009                    println!("{}", serde_json::to_string_pretty(&payload)?);
2010                } else {
2011                    println!("{}", serde_json::to_string(&payload)?);
2012                }
2013            } else {
2014                println!(
2015                    "reaped {} stale holder(s); {} profile(s) dropped to zero references",
2016                    reap.reclaimed.len(),
2017                    reap.emptied_profiles.len()
2018                );
2019                for profile_id in &reap.emptied_profiles {
2020                    println!("  emptied: {profile_id}");
2021                }
2022                if !unloaded.is_empty() {
2023                    println!("unloaded {} unreferenced model(s)", unloaded.len());
2024                }
2025                println!("registry: {}", path.display());
2026            }
2027            Ok(())
2028        }
2029        LeaseCommand::Show {
2030            lease_file,
2031            include_stale,
2032            ..
2033        } => {
2034            let path = resolve_lease_file(lease_file.as_deref());
2035            let registry = show_registry(&path, now, include_stale)?;
2036            if output.json_output {
2037                if output.pretty {
2038                    println!("{}", serde_json::to_string_pretty(&registry)?);
2039                } else {
2040                    println!("{}", serde_json::to_string(&registry)?);
2041                }
2042            } else {
2043                print!("{}", format_lease_show_human(&registry, now));
2044            }
2045            println!("registry: {}", path.display());
2046            Ok(())
2047        }
2048    }
2049}
2050
2051fn lifecycle_probe(
2052    no_probe: bool,
2053    synthetic_used_mib: Option<u64>,
2054    skipped_reason: &str,
2055) -> tsift_local_model::GpuProbe {
2056    if let Some(used_mib) = synthetic_used_mib {
2057        return tsift_local_model::GpuProbe::synthetic_vram(used_mib);
2058    }
2059    if no_probe {
2060        return tsift_local_model::GpuProbe::unavailable(skipped_reason);
2061    }
2062    tsift_local_model::probe_nvidia_smi()
2063}
2064
2065/// Classify a task description into a model tier.
2066/// Returns (tier_name, model_id).
2067pub fn classify_task(task: &str) -> (&'static str, &'static str) {
2068    let lower = task.to_lowercase();
2069    // Architecture/design signals → opus
2070    for signal in &[
2071        "architect",
2072        "architecture",
2073        "design",
2074        "plan",
2075        "strateg",
2076        "analy",
2077        "review",
2078        "evaluate",
2079        "assess",
2080    ] {
2081        if lower.contains(signal) {
2082            return ("opus", "claude-opus-4-6");
2083        }
2084    }
2085    // Edit/write signals → sonnet
2086    for signal in &[
2087        "edit",
2088        "write",
2089        "fix",
2090        "change",
2091        "update",
2092        "create",
2093        "add ",
2094        "remove",
2095        "delete",
2096        "modify",
2097        "refactor",
2098        "implement",
2099        "build",
2100    ] {
2101        if lower.contains(signal) {
2102            return ("sonnet", "claude-sonnet-4-6");
2103        }
2104    }
2105    // Default: search/lookup → haiku
2106    ("haiku", "claude-haiku-4-5-20251001")
2107}
2108
2109#[cfg(test)]
2110fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
2111    to_json_schema(val, pretty, terse, false, false)
2112}
2113
2114/// Add top-level `tagpath_index_stale: true` + `tagpath_stale_reason: <reason>`
2115/// fields to a JSON response when the tagpath adapter reported any helper
2116/// going stale. JSON consumers (`tsift --envelope` / `--json` callers) can
2117/// then act on the same condition the stderr `tagpath_index_stale: …` log
2118/// already surfaces without parsing logs. No-op when `stale=false` or when
2119/// `value` is not a JSON object.
2120pub(crate) fn inject_tagpath_stale_into_json(
2121    value: &mut serde_json::Value,
2122    stale: bool,
2123    reason: Option<&str>,
2124) {
2125    if !stale {
2126        return;
2127    }
2128    if let Some(obj) = value.as_object_mut() {
2129        obj.insert(
2130            "tagpath_index_stale".to_string(),
2131            serde_json::Value::Bool(true),
2132        );
2133        if let Some(reason) = reason {
2134            obj.insert(
2135                "tagpath_stale_reason".to_string(),
2136                serde_json::Value::String(reason.to_string()),
2137            );
2138        }
2139    }
2140}
2141
2142pub(crate) fn to_json_schema<T: serde::Serialize>(
2143    val: &T,
2144    pretty: bool,
2145    terse: bool,
2146    ultra_terse: bool,
2147    schema: bool,
2148) -> anyhow::Result<String> {
2149    if terse || schema {
2150        let value = serde_json::to_value(val)?;
2151        let mut transformed = if terse { terse_transform(value) } else { value };
2152        if ultra_terse {
2153            transformed = ultra_terse_transform(transformed);
2154            transformed = edge_index_transform(transformed);
2155        }
2156        if schema {
2157            transformed = schema_transform(transformed);
2158        }
2159        if terse {
2160            let terse_schema = terse_schema_for(&transformed);
2161            let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
2162            if pretty {
2163                Ok(serde_json::to_string_pretty(&wrapped)?)
2164            } else {
2165                Ok(serde_json::to_string(&wrapped)?)
2166            }
2167        } else if pretty {
2168            Ok(serde_json::to_string_pretty(&transformed)?)
2169        } else {
2170            Ok(serde_json::to_string(&transformed)?)
2171        }
2172    } else if pretty {
2173        Ok(serde_json::to_string_pretty(val)?)
2174    } else {
2175        Ok(serde_json::to_string(val)?)
2176    }
2177}
2178
2179pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
2180    ToolEnvelopeMetric {
2181        label: label.to_string(),
2182        value: value.to_string(),
2183    }
2184}
2185
2186pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
2187    let mut seen = HashSet::new();
2188    let mut deduped = Vec::new();
2189    for value in values {
2190        if seen.insert(value.clone()) {
2191            deduped.push(value);
2192        }
2193    }
2194    deduped
2195}
2196
2197pub(crate) fn print_json_or_envelope<T: Serialize>(
2198    report: &T,
2199    format: &OutputFormat,
2200    tool: &str,
2201    view: &str,
2202    summary: ToolEnvelopeSummary,
2203    truncated: bool,
2204    follow_up: Vec<String>,
2205) -> Result<()> {
2206    if format.envelope {
2207        let schema = format.schema || tool == "source-read";
2208        let envelope = ToolEnvelope {
2209            tool,
2210            view,
2211            summary,
2212            truncated,
2213            follow_up: dedupe_preserve_order(follow_up),
2214            report,
2215        };
2216        println!(
2217            "{}",
2218            to_json_schema(
2219                &envelope,
2220                format.pretty,
2221                format.terse,
2222                format.ultra_terse,
2223                schema
2224            )?
2225        );
2226    } else {
2227        println!(
2228            "{}",
2229            to_json_schema(
2230                report,
2231                format.pretty,
2232                format.terse,
2233                format.ultra_terse,
2234                format.schema
2235            )?
2236        );
2237    }
2238    Ok(())
2239}
2240
2241pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
2242    bytes.div_ceil(4)
2243}
2244
2245fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
2246    match command {
2247        cli::TokenGateCommand::Sample {
2248            surface,
2249            path,
2250            scope,
2251            target,
2252            depth,
2253            sample_index,
2254            json: _,
2255        } => cmd_token_gate_sample(
2256            &surface,
2257            &path,
2258            scope.as_deref(),
2259            target.as_deref(),
2260            depth,
2261            sample_index,
2262        ),
2263        cli::TokenGateCommand::Evaluate {
2264            history,
2265            allowed_regression_percent,
2266            json: _,
2267        } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
2268    }
2269}
2270
2271fn cmd_token_gate_sample(
2272    surface: &str,
2273    path: &Path,
2274    scope: Option<&str>,
2275    target: Option<&str>,
2276    depth: usize,
2277    sample_index: usize,
2278) -> Result<()> {
2279    if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
2280        bail!(
2281            "unknown surface `{}`; expected one of: {}",
2282            surface,
2283            token_gate::TOKEN_GATE_SURFACES.join(", ")
2284        );
2285    }
2286
2287    let path_str = path.to_string_lossy().to_string();
2288    let tsift_bin = std::env::current_exe()?;
2289
2290    let args: Vec<String> = match surface {
2291        "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
2292        "session_review_next_context" => vec![
2293            "session-review".to_string(),
2294            "--json".to_string(),
2295            "--next-context".to_string(),
2296            path_str,
2297        ],
2298        "graph_db_evidence" => {
2299            let tgt = target.unwrap_or("default").to_string();
2300            vec![
2301                "graph-db".to_string(),
2302                "--json".to_string(),
2303                "--path".to_string(),
2304                path_str,
2305                "evidence".to_string(),
2306                tgt,
2307                "--depth".to_string(),
2308                depth.to_string(),
2309            ]
2310        }
2311        "conflict_matrix" => {
2312            let tgt = target.unwrap_or("default").to_string();
2313            let mut a = vec![
2314                "conflict-matrix".to_string(),
2315                "--json".to_string(),
2316                "--path".to_string(),
2317                path_str,
2318                "--depth".to_string(),
2319                depth.to_string(),
2320            ];
2321            if let Some(s) = scope {
2322                a.push("--scope".to_string());
2323                a.push(s.to_string());
2324            }
2325            a.push(tgt);
2326            a
2327        }
2328        "dispatch_trace" => {
2329            let tgt = target.unwrap_or("default").to_string();
2330            vec![
2331                "dispatch-trace".to_string(),
2332                "--json".to_string(),
2333                "--path".to_string(),
2334                path_str,
2335                tgt,
2336            ]
2337        }
2338        _ => bail!("unhandled surface: {}", surface),
2339    };
2340
2341    let start = Instant::now();
2342    let child = Command::new(&tsift_bin)
2343        .args(&args)
2344        .stdout(Stdio::piped())
2345        .stderr(Stdio::piped())
2346        .env("TSIFT_QUIET", "1")
2347        .spawn();
2348    let output = match child {
2349        Ok(c) => c.wait_with_output()?,
2350        Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
2351    };
2352    let runtime_micros = start.elapsed().as_micros() as f64;
2353
2354    let stdout = String::from_utf8_lossy(&output.stdout);
2355    let envelope_bytes = stdout.trim().len() as f64;
2356    let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
2357
2358    let cache_hit_rate_percent = 0.0;
2359    let raw_read_avoidance = 0.0;
2360    let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
2361
2362    let timestamp = iso_timestamp_now();
2363    let id = format!(
2364        "{surface}-baseline-{}-sample-{sample_index}",
2365        &timestamp[..10]
2366    );
2367    let label = format!(
2368        "token-gate baseline {surface} sample {sample_index} for {}",
2369        path.display()
2370    );
2371
2372    let mut metrics = BTreeMap::new();
2373    metrics.insert("prompt_tokens".to_string(), prompt_tokens);
2374    metrics.insert("envelope_bytes".to_string(), envelope_bytes);
2375    metrics.insert("runtime_micros".to_string(), runtime_micros);
2376    metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
2377    metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
2378    metrics.insert("useful_hit_density".to_string(), useful_hit_density);
2379
2380    let sample = token_gate::TokenGateSample {
2381        label,
2382        id,
2383        timestamp: Some(timestamp),
2384        surface: surface.to_string(),
2385        metrics,
2386    };
2387
2388    println!("{}", serde_json::to_string_pretty(&sample)?);
2389    Ok(())
2390}
2391
2392fn cmd_token_gate_evaluate(
2393    history_path: Option<&Path>,
2394    allowed_regression_percent: f64,
2395    format: &OutputFormat,
2396) -> Result<()> {
2397    let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
2398        let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2399        p.push("../../fixtures/token-gate-history.json");
2400        p
2401    });
2402
2403    let raw = std::fs::read_to_string(&history_path).with_context(|| {
2404        format!(
2405            "failed to read token gate history: {}",
2406            history_path.display()
2407        )
2408    })?;
2409    let samples = token_gate::parse_token_history(&raw)?;
2410    let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
2411
2412    if format.json_output {
2413        println!(
2414            "{}",
2415            to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
2416        );
2417    } else {
2418        println!("Token Gate Report");
2419        println!("  min_samples: {}", report.min_samples);
2420        println!(
2421            "  allowed_regression: {:.1}%",
2422            report.allowed_regression_percent
2423        );
2424        println!("  decision: {:?}", report.decision);
2425        for eval in &report.surface_evaluations {
2426            println!(
2427                "  {} ({} samples): {:?}",
2428                eval.display_name, eval.sample_count, eval.verdict
2429            );
2430            for me in &eval.metric_evaluations {
2431                println!("    {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
2432            }
2433        }
2434        for d in &report.diagnostics {
2435            println!("  ! {}", d);
2436        }
2437    }
2438    Ok(())
2439}
2440
2441fn iso_timestamp_now() -> String {
2442    let dur = SystemTime::now()
2443        .duration_since(UNIX_EPOCH)
2444        .unwrap_or_default();
2445    let total_secs = dur.as_secs();
2446    let days_since_epoch = total_secs / 86400;
2447    let (year, month, day) = days_to_ymd(days_since_epoch);
2448    let time_of_day = total_secs % 86400;
2449    let hour = (time_of_day / 3600) as u8;
2450    let minute = ((time_of_day % 3600) / 60) as u8;
2451    let second = (time_of_day % 60) as u8;
2452    format!(
2453        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
2454        year, month, day, hour, minute, second
2455    )
2456}
2457
2458fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
2459    let mut year = 1970u64;
2460    loop {
2461        let days_in_year = if is_leap(year) { 366 } else { 365 };
2462        if days < days_in_year {
2463            break;
2464        }
2465        days -= days_in_year;
2466        year += 1;
2467    }
2468    let leap = is_leap(year);
2469    let month_days: [u8; 12] = if leap {
2470        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2471    } else {
2472        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2473    };
2474    let mut month: u8 = 1;
2475    for &md in &month_days {
2476        if days < md as u64 {
2477            break;
2478        }
2479        days -= md as u64;
2480        month += 1;
2481    }
2482    let day = days as u8 + 1;
2483    (year, month, day)
2484}
2485
2486fn is_leap(year: u64) -> bool {
2487    year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
2488}
2489
2490fn persist_transcript_artifact(
2491    root: &Path,
2492    prefix: &str,
2493    suffix: &str,
2494    key: &str,
2495    body: &str,
2496    expand: String,
2497) -> Result<TranscriptArtifactRef> {
2498    let handle = stable_handle(prefix, key);
2499    let artifacts_dir = root.join(".tsift/artifacts");
2500    fs::create_dir_all(&artifacts_dir).with_context(|| {
2501        format!(
2502            "creating transcript artifacts dir: {}",
2503            artifacts_dir.display()
2504        )
2505    })?;
2506    let file_name = format!("{handle}.{suffix}");
2507    let artifact_path = artifacts_dir.join(file_name);
2508    fs::write(&artifact_path, body)
2509        .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
2510    let rel_path = relativize_pathbuf(&artifact_path, root);
2511    Ok(TranscriptArtifactRef {
2512        handle,
2513        path: rel_path.display().to_string(),
2514        bytes: body.len(),
2515        lines: body.lines().count(),
2516        expand,
2517    })
2518}
2519
2520fn terse_key(key: &str) -> &str {
2521    match key {
2522        "name" => "n",
2523        "kind" => "k",
2524        "file" => "f",
2525        "line" => "l",
2526        "path" => "p",
2527        "from" => "fr",
2528        "type" => "ty",
2529        "text" => "tx",
2530        "new" => "nw",
2531        "run" => "r",
2532        "use" => "u",
2533        "score" => "sc",
2534        "language" => "la",
2535        "status" => "st",
2536        "state" => "stt",
2537        "error" => "err",
2538        "errors" => "ers",
2539        "hops" => "hp",
2540        "tags" => "tg",
2541        "model" => "ml",
2542        "skill" => "sk",
2543        "count" => "ct",
2544        "total" => "tot",
2545        "column" => "col",
2546        "description" => "dsc",
2547        "end_line" => "el",
2548        "signature" => "sig",
2549        "parent_module" => "pm",
2550        "visibility" => "vis",
2551        "match_type" => "mt",
2552        "caller_file" => "cf",
2553        "caller_name" => "cn",
2554        "caller_line" => "cl",
2555        "callee_name" => "en",
2556        "call_site_line" => "csl",
2557        "members" => "m",
2558        "refs" => "refs",
2559        "role" => "rl",
2560        "peer" => "pr",
2561        "modularity" => "q",
2562        "modularity_contribution" => "mc",
2563        "iterations" => "it",
2564        "node_count" => "nc",
2565        "edge_count" => "ec",
2566        "community_count" => "cc",
2567        "communities" => "cms",
2568        "community" => "cm",
2569        "community_diagnostics" => "cd",
2570        "cache_hit" => "cah",
2571        "tagpath_state" => "tps",
2572        "tagpath_stale_reason" => "tsr",
2573        "annotated_community_count" => "acc",
2574        "annotated_member_count" => "amc",
2575        "ambiguous_member_count" => "ambc",
2576        "ambiguous_members" => "amb",
2577        "candidate_count" => "cand",
2578        "tagpath_candidate_count" => "tcand",
2579        "evidence" => "ev",
2580        "chosen_file" => "chf",
2581        "symbol" => "s",
2582        "symbols" => "sy",
2583        "definitions" => "df",
2584        "callers" => "crs",
2585        "callees" => "ces",
2586        "total_tracked" => "tt",
2587        "modified" => "md",
2588        "deleted" => "dl",
2589        "unchanged" => "uc",
2590        "changes" => "ch",
2591        "prune_stats" => "ps",
2592        "hits" => "h",
2593        "rank" => "rk",
2594        "snippet" => "sn",
2595        "confidence" => "co",
2596        "index" => "ix",
2597        "summaries" => "sms",
2598        "recommendations" => "rec",
2599        "total_files" => "tf",
2600        "stale_files" => "sf",
2601        "last_indexed_secs_ago" => "age",
2602        "cached_files" => "caf",
2603        "total_indexed_files" => "tif",
2604        "coverage_pct" => "cov",
2605        "symbol_name" => "syn",
2606        "file_path" => "fp",
2607        "content_hash" => "hsh",
2608        "summary" => "sum",
2609        "tool" => "tl",
2610        "view" => "vw",
2611        "truncated" => "tr",
2612        "follow_up" => "fu",
2613        "report" => "rp",
2614        "metrics" => "ms",
2615        "label" => "lb",
2616        "value" => "v",
2617        "command" => "cmd",
2618        "exit_code" => "xc",
2619        "success" => "ok",
2620        "artifact" => "art",
2621        "digest" => "dg",
2622        "bytes" => "bt",
2623        "lines" => "lns",
2624        "expand" => "xp",
2625        "entities" => "ent",
2626        "relationships" => "rel",
2627        "concept_labels" => "cls",
2628        "extracted_at" => "at",
2629        "tokens_input" => "ti",
2630        "tokens_output" => "tout",
2631        "total_summaries" => "ts",
2632        "stale_count" => "stc",
2633        "total_tokens_input" => "tti",
2634        "total_tokens_output" => "tto",
2635        "estimated_tokens_saved" => "ets",
2636        "files_processed" => "fps",
2637        "symbols_extracted" => "se",
2638        "skills_dir" => "sd",
2639        "healthy" => "ok",
2640        "broken" => "brk",
2641        "skills" => "sks",
2642        "manifest_diffs" => "mdf",
2643        "similar_pairs" => "sim",
2644        "usage" => "usg",
2645        "cleanup" => "cln",
2646        "has_skill_md" => "hsm",
2647        "is_symlink" => "isl",
2648        "issues" => "iss",
2649        "invocation_count" => "inv",
2650        "reasons" => "rsn",
2651        "token_estimate" => "te",
2652        "skill_a" => "sa",
2653        "skill_b" => "sb",
2654        "desc_a" => "da",
2655        "desc_b" => "db",
2656        "annotations" => "ann",
2657        "entity" => "ety",
2658        "suggestion" => "sug",
2659        "columns" => "cols",
2660        "row_count" => "rc",
2661        "notnull" => "nn",
2662        "default_value" => "dv",
2663        "replace_all" => "ra",
2664        other => other,
2665    }
2666}
2667
2668fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2669    match val {
2670        serde_json::Value::Object(map) => {
2671            let mut new_map = serde_json::Map::new();
2672            for (k, v) in map {
2673                new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2674            }
2675            serde_json::Value::Object(new_map)
2676        }
2677        serde_json::Value::Array(arr) => {
2678            serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2679        }
2680        other => other,
2681    }
2682}
2683
2684fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2685    match val {
2686        serde_json::Value::Object(mut map) => {
2687            let is_graph_node =
2688                map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2689            let is_graph_edge =
2690                map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2691            if is_graph_node || is_graph_edge {
2692                map.remove("properties");
2693                map.remove("provenance");
2694                map.remove("freshness");
2695            }
2696            if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2697                *s = abbreviate_edge_kind(s).to_string();
2698            }
2699            let is_coverage = map.contains_key("mode")
2700                && (map.contains_key("total_sector_count")
2701                    || map.contains_key("dirty_sector_count"));
2702            if is_coverage {
2703                map.remove("active_rebuild");
2704                map.remove("completed_dirty_sector_count");
2705                map.remove("mounted_sector_count");
2706                map.remove("rebuilding_sector_count");
2707                map.remove("resumed_sector_count");
2708                map.remove("reused_sector_count");
2709            }
2710            if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2711                *s = truncate_for_ultra_terse(s, 80);
2712            }
2713            if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2714                *s = truncate_for_ultra_terse(s, 80);
2715            }
2716            let new_map: serde_json::Map<String, serde_json::Value> = map
2717                .into_iter()
2718                .map(|(k, v)| (k, ultra_terse_transform(v)))
2719                .collect();
2720            serde_json::Value::Object(new_map)
2721        }
2722        serde_json::Value::Array(arr) => {
2723            serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2724        }
2725        other => other,
2726    }
2727}
2728
2729fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2730    match val {
2731        serde_json::Value::Object(mut map) => {
2732            let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2733                nodes.as_array().map(|arr| {
2734                    arr.iter()
2735                        .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2736                        .collect()
2737                })
2738            });
2739            if let Some(ref ids) = node_ids {
2740                let id_map: std::collections::HashMap<&str, usize> = ids
2741                    .iter()
2742                    .enumerate()
2743                    .map(|(i, id)| (id.as_str(), i))
2744                    .collect();
2745                if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2746                    for edge in edges.iter_mut() {
2747                        if let serde_json::Value::Object(edge_map) = edge {
2748                            if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2749                            {
2750                                if let Some(&idx) = id_map.get(fid.as_str()) {
2751                                    edge_map.insert(
2752                                        "from".to_string(),
2753                                        serde_json::Value::Number(idx.into()),
2754                                    );
2755                                } else {
2756                                    edge_map.insert(
2757                                        "from_id".to_string(),
2758                                        serde_json::Value::String(fid),
2759                                    );
2760                                }
2761                            }
2762                            if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2763                                if let Some(&idx) = id_map.get(tid.as_str()) {
2764                                    edge_map.insert(
2765                                        "to".to_string(),
2766                                        serde_json::Value::Number(idx.into()),
2767                                    );
2768                                } else {
2769                                    edge_map.insert(
2770                                        "to_id".to_string(),
2771                                        serde_json::Value::String(tid),
2772                                    );
2773                                }
2774                            }
2775                        }
2776                    }
2777                }
2778            }
2779            let new_map: serde_json::Map<String, serde_json::Value> = map
2780                .into_iter()
2781                .map(|(k, v)| (k, edge_index_transform(v)))
2782                .collect();
2783            serde_json::Value::Object(new_map)
2784        }
2785        serde_json::Value::Array(arr) => {
2786            serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2787        }
2788        other => other,
2789    }
2790}
2791
2792fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2793    if s.len() <= max_len {
2794        s.to_string()
2795    } else {
2796        let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2797        format!("{truncated}...")
2798    }
2799}
2800
2801fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2802    let mut keys = HashSet::new();
2803    collect_terse_keys(val, &mut keys);
2804    let mut schema = serde_json::Map::new();
2805    for (long, short) in TERSE_PAIRS {
2806        if keys.contains(*short) {
2807            schema.insert(
2808                short.to_string(),
2809                serde_json::Value::String(long.to_string()),
2810            );
2811        }
2812    }
2813    serde_json::Value::Object(schema)
2814}
2815
2816fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2817    match val {
2818        serde_json::Value::Object(map) => {
2819            for (k, v) in map {
2820                keys.insert(k.clone());
2821                collect_terse_keys(v, keys);
2822            }
2823        }
2824        serde_json::Value::Array(arr) => {
2825            for v in arr {
2826                collect_terse_keys(v, keys);
2827            }
2828        }
2829        _ => {}
2830    }
2831}
2832
2833fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2834    match val {
2835        serde_json::Value::Array(arr) if arr.len() >= 2 => {
2836            if let Some(cols) = homogeneous_keys(&arr) {
2837                let rows: Vec<serde_json::Value> = arr
2838                    .into_iter()
2839                    .map(|item| {
2840                        if let serde_json::Value::Object(map) = item {
2841                            let vals: Vec<serde_json::Value> = cols
2842                                .iter()
2843                                .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2844                                .collect();
2845                            serde_json::Value::Array(vals)
2846                        } else {
2847                            item
2848                        }
2849                    })
2850                    .collect();
2851                let col_vals: Vec<serde_json::Value> =
2852                    cols.into_iter().map(serde_json::Value::String).collect();
2853                serde_json::json!({"_c": col_vals, "_r": rows})
2854            } else {
2855                serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2856            }
2857        }
2858        serde_json::Value::Array(arr) => {
2859            serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2860        }
2861        serde_json::Value::Object(map) => {
2862            let new_map: serde_json::Map<String, serde_json::Value> = map
2863                .into_iter()
2864                .map(|(k, v)| (k, schema_transform(v)))
2865                .collect();
2866            serde_json::Value::Object(new_map)
2867        }
2868        other => other,
2869    }
2870}
2871
2872fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2873    let first = arr.first()?.as_object()?;
2874    let keys: Vec<String> = first.keys().cloned().collect();
2875    for item in &arr[1..] {
2876        let obj = item.as_object()?;
2877        if obj.len() != keys.len() {
2878            return None;
2879        }
2880        for k in &keys {
2881            if !obj.contains_key(k) {
2882                return None;
2883            }
2884        }
2885    }
2886    Some(keys)
2887}
2888
2889const TERSE_PAIRS: &[(&str, &str)] = &[
2890    ("name", "n"),
2891    ("kind", "k"),
2892    ("file", "f"),
2893    ("line", "l"),
2894    ("path", "p"),
2895    ("from", "fr"),
2896    ("type", "ty"),
2897    ("text", "tx"),
2898    ("new", "nw"),
2899    ("run", "r"),
2900    ("use", "u"),
2901    ("score", "sc"),
2902    ("language", "la"),
2903    ("status", "st"),
2904    ("state", "stt"),
2905    ("error", "err"),
2906    ("errors", "ers"),
2907    ("hops", "hp"),
2908    ("tags", "tg"),
2909    ("model", "ml"),
2910    ("skill", "sk"),
2911    ("count", "ct"),
2912    ("total", "tot"),
2913    ("column", "col"),
2914    ("description", "dsc"),
2915    ("end_line", "el"),
2916    ("signature", "sig"),
2917    ("parent_module", "pm"),
2918    ("visibility", "vis"),
2919    ("match_type", "mt"),
2920    ("caller_file", "cf"),
2921    ("caller_name", "cn"),
2922    ("caller_line", "cl"),
2923    ("callee_name", "en"),
2924    ("call_site_line", "csl"),
2925    ("members", "m"),
2926    ("refs", "refs"),
2927    ("role", "rl"),
2928    ("peer", "pr"),
2929    ("modularity", "q"),
2930    ("modularity_contribution", "mc"),
2931    ("iterations", "it"),
2932    ("node_count", "nc"),
2933    ("edge_count", "ec"),
2934    ("community_count", "cc"),
2935    ("communities", "cms"),
2936    ("community", "cm"),
2937    ("community_diagnostics", "cd"),
2938    ("cache_hit", "cah"),
2939    ("tagpath_state", "tps"),
2940    ("tagpath_stale_reason", "tsr"),
2941    ("annotated_community_count", "acc"),
2942    ("annotated_member_count", "amc"),
2943    ("ambiguous_member_count", "ambc"),
2944    ("ambiguous_members", "amb"),
2945    ("candidate_count", "cand"),
2946    ("tagpath_candidate_count", "tcand"),
2947    ("evidence", "ev"),
2948    ("chosen_file", "chf"),
2949    ("symbol", "s"),
2950    ("symbols", "sy"),
2951    ("definitions", "df"),
2952    ("callers", "crs"),
2953    ("callees", "ces"),
2954    ("total_tracked", "tt"),
2955    ("modified", "md"),
2956    ("deleted", "dl"),
2957    ("unchanged", "uc"),
2958    ("changes", "ch"),
2959    ("prune_stats", "ps"),
2960    ("hits", "h"),
2961    ("rank", "rk"),
2962    ("snippet", "sn"),
2963    ("confidence", "co"),
2964    ("index", "ix"),
2965    ("summaries", "sms"),
2966    ("recommendations", "rec"),
2967    ("total_files", "tf"),
2968    ("stale_files", "sf"),
2969    ("last_indexed_secs_ago", "age"),
2970    ("cached_files", "caf"),
2971    ("total_indexed_files", "tif"),
2972    ("coverage_pct", "cov"),
2973    ("symbol_name", "syn"),
2974    ("file_path", "fp"),
2975    ("content_hash", "hsh"),
2976    ("summary", "sum"),
2977    ("tool", "tl"),
2978    ("view", "vw"),
2979    ("truncated", "tr"),
2980    ("follow_up", "fu"),
2981    ("report", "rp"),
2982    ("metrics", "ms"),
2983    ("label", "lb"),
2984    ("value", "v"),
2985    ("command", "cmd"),
2986    ("exit_code", "xc"),
2987    ("success", "ok"),
2988    ("artifact", "art"),
2989    ("digest", "dg"),
2990    ("bytes", "bt"),
2991    ("lines", "lns"),
2992    ("expand", "xp"),
2993    ("entities", "ent"),
2994    ("relationships", "rel"),
2995    ("concept_labels", "cls"),
2996    ("extracted_at", "at"),
2997    ("tokens_input", "ti"),
2998    ("tokens_output", "tout"),
2999    ("total_summaries", "ts"),
3000    ("stale_count", "stc"),
3001    ("total_tokens_input", "tti"),
3002    ("total_tokens_output", "tto"),
3003    ("estimated_tokens_saved", "ets"),
3004    ("files_processed", "fps"),
3005    ("symbols_extracted", "se"),
3006    ("skills_dir", "sd"),
3007    ("healthy", "ok"),
3008    ("broken", "brk"),
3009    ("skills", "sks"),
3010    ("manifest_diffs", "mdf"),
3011    ("similar_pairs", "sim"),
3012    ("usage", "usg"),
3013    ("cleanup", "cln"),
3014    ("has_skill_md", "hsm"),
3015    ("is_symlink", "isl"),
3016    ("issues", "iss"),
3017    ("invocation_count", "inv"),
3018    ("reasons", "rsn"),
3019    ("token_estimate", "te"),
3020    ("skill_a", "sa"),
3021    ("skill_b", "sb"),
3022    ("desc_a", "da"),
3023    ("desc_b", "db"),
3024    ("annotations", "ann"),
3025    ("entity", "ety"),
3026    ("suggestion", "sug"),
3027    ("columns", "cols"),
3028    ("row_count", "rc"),
3029    ("notnull", "nn"),
3030    ("default_value", "dv"),
3031    ("replace_all", "ra"),
3032];
3033
3034pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
3035    let root_str = root.to_string_lossy();
3036    let prefix = format!("{}/", root_str.trim_end_matches('/'));
3037    path.strip_prefix(&prefix).unwrap_or(path).to_string()
3038}
3039
3040fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
3041    let canonical = path
3042        .canonicalize()
3043        .with_context(|| format!("canonicalizing {}", path.display()))?;
3044    let start = if canonical.is_dir() {
3045        canonical.clone()
3046    } else {
3047        canonical
3048            .parent()
3049            .map(Path::to_path_buf)
3050            .unwrap_or_else(|| canonical.clone())
3051    };
3052
3053    for ancestor in start.ancestors() {
3054        if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
3055            return Ok(ancestor.to_path_buf());
3056        }
3057    }
3058
3059    Ok(start)
3060}
3061
3062pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
3063    path.strip_prefix(root)
3064        .map(|p| p.to_path_buf())
3065        .unwrap_or_else(|_| path.to_path_buf())
3066}
3067
3068pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
3069    for edge in edges {
3070        edge.caller_file = relativize(&edge.caller_file, root);
3071    }
3072}
3073
3074pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
3075    for sym in symbols {
3076        sym.file = relativize(&sym.file, root);
3077    }
3078}
3079
3080pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
3081    for hit in hits {
3082        hit.file = relativize(&hit.file, root);
3083    }
3084}
3085
3086/// Which endpoint of a `StoredEdge` is the row's primary symbol — caller
3087/// (caller list) or callee (callee list).
3088#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3089pub enum EdgeSide {
3090    Caller,
3091    Callee,
3092}
3093
3094const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
3095
3096pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
3097    let root_str = root.to_string_lossy();
3098    let prefix = format!("{}/", root_str.trim_end_matches('/'));
3099    relativize_json_inner(val, &prefix);
3100}
3101
3102fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
3103    match val {
3104        serde_json::Value::Array(arr) => {
3105            for v in arr {
3106                relativize_json_inner(v, prefix);
3107            }
3108        }
3109        serde_json::Value::Object(map) => {
3110            for (k, v) in map.iter_mut() {
3111                if JSON_PATH_KEYS.contains(&k.as_str())
3112                    && let serde_json::Value::String(s) = v
3113                    && let Some(rest) = s.strip_prefix(prefix)
3114                {
3115                    *s = rest.to_string();
3116                }
3117                relativize_json_inner(v, prefix);
3118            }
3119        }
3120        _ => {}
3121    }
3122}
3123
3124pub(crate) fn format_score(score: f64, compact: bool) -> String {
3125    if compact {
3126        format!("{score:.2}")
3127    } else {
3128        format!("{score:.4}")
3129    }
3130}
3131
3132pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
3133    let trimmed = input.trim();
3134    let count = trimmed.chars().count();
3135    if count <= max_chars {
3136        return trimmed.to_string();
3137    }
3138    let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
3139    format!("{prefix}...")
3140}
3141
3142pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
3143    snippet
3144        .lines()
3145        .find(|line| !line.trim().is_empty())
3146        .map(|line| truncate_for_compact(line, 100))
3147}
3148
3149pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
3150    let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
3151    if names.len() <= limit {
3152        return names.join(", ");
3153    }
3154    format!(
3155        "{} (+{} more)",
3156        names[..limit].join(", "),
3157        names.len() - limit
3158    )
3159}
3160
3161pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
3162    let mut hasher = blake3::Hasher::new();
3163    hasher.update(prefix.as_bytes());
3164    hasher.update(&[0]);
3165    hasher.update(key.as_bytes());
3166    let hex = hasher.finalize().to_hex();
3167    format!("{prefix}-{}", &hex[..10])
3168}
3169
3170#[derive(Clone, Debug, PartialEq, Eq)]
3171struct CanonicalTagFamily {
3172    canonical: String,
3173    tag_alias: String,
3174}
3175
3176fn canonical_family_from_tagpath_family(
3177    family: tagpath_family::TagFamily,
3178) -> Option<CanonicalTagFamily> {
3179    let tag_alias = if family.dimensions.is_empty() {
3180        family.tags.join("/")
3181    } else {
3182        family
3183            .dimensions
3184            .iter()
3185            .filter(|dimension| !dimension.tags.is_empty())
3186            .map(|dimension| dimension.tags.join("."))
3187            .collect::<Vec<_>>()
3188            .join("/")
3189    };
3190
3191    if tag_alias.is_empty() {
3192        None
3193    } else {
3194        Some(CanonicalTagFamily {
3195            canonical: family.canonical,
3196            tag_alias,
3197        })
3198    }
3199}
3200
3201fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
3202    let trimmed = name.trim();
3203    if trimmed.is_empty() {
3204        return None;
3205    }
3206
3207    canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
3208}
3209
3210fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
3211    let canonical = tags
3212        .split(',')
3213        .map(str::trim)
3214        .filter(|tag| !tag.is_empty())
3215        .collect::<Vec<_>>()
3216        .join("_");
3217    if canonical.is_empty() {
3218        None
3219    } else {
3220        canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
3221    }
3222}
3223
3224pub(crate) fn canonical_tag_family_from_symbol(
3225    name: &str,
3226    tags: Option<&str>,
3227) -> Option<CanonicalTagFamily> {
3228    tags.and_then(canonical_tag_family_from_tags)
3229        .or_else(|| canonical_tag_family_from_name(name))
3230}
3231
3232fn tag_alias_from_name(name: &str) -> Option<String> {
3233    canonical_tag_family_from_name(name).map(|family| family.tag_alias)
3234}
3235
3236fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
3237    canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
3238}
3239
3240pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
3241    let query = tag_alias
3242        .split(['/', '.'])
3243        .map(str::trim)
3244        .filter(|part| !part.is_empty())
3245        .collect::<Vec<_>>()
3246        .join(" ");
3247    if query.is_empty() { None } else { Some(query) }
3248}
3249
3250#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3251struct CompactOntologyRefPreview {
3252    handle: String,
3253    tag: String,
3254    path: String,
3255    #[serde(skip_serializing_if = "Option::is_none")]
3256    title: Option<String>,
3257    #[serde(skip_serializing_if = "Option::is_none")]
3258    domain: Option<String>,
3259}
3260
3261#[derive(Clone, Debug)]
3262struct TagOntologyPreviewContext {
3263    project_root: PathBuf,
3264    tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
3265}
3266
3267#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3268struct CompactSymbolRefPreview {
3269    handle: String,
3270    name: String,
3271    #[serde(skip_serializing_if = "Option::is_none")]
3272    tag_alias: Option<String>,
3273    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3274    ontology_refs: Vec<CompactOntologyRefPreview>,
3275}
3276
3277fn build_compact_symbol_ref(
3278    prefix: &str,
3279    key: &str,
3280    name: &str,
3281    tags: Option<&str>,
3282    max_bytes: usize,
3283) -> CompactSymbolRefPreview {
3284    build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
3285}
3286
3287fn build_compact_symbol_ref_with_ontology(
3288    prefix: &str,
3289    key: &str,
3290    name: &str,
3291    tags: Option<&str>,
3292    max_bytes: usize,
3293    ontology: Option<&TagOntologyPreviewContext>,
3294) -> CompactSymbolRefPreview {
3295    let tag_alias = tag_alias_from_tags(name, tags);
3296    let ontology_refs = tag_alias
3297        .as_deref()
3298        .map(|alias| ontology_refs_for_alias(ontology, alias))
3299        .unwrap_or_default();
3300    CompactSymbolRefPreview {
3301        handle: stable_handle(prefix, key),
3302        name: truncate_for_budget(name, max_bytes),
3303        tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
3304        ontology_refs,
3305    }
3306}
3307
3308fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
3309    let report = tagpath_ontology::load_project(root).ok()?;
3310    if report.tags.is_empty() {
3311        return None;
3312    }
3313    Some(TagOntologyPreviewContext {
3314        project_root: report.project_path,
3315        tags: report
3316            .tags
3317            .into_iter()
3318            .map(|tag| (tag.tag.clone(), tag))
3319            .collect(),
3320    })
3321}
3322
3323fn ontology_refs_for_alias(
3324    ontology: Option<&TagOntologyPreviewContext>,
3325    alias: &str,
3326) -> Vec<CompactOntologyRefPreview> {
3327    let Some(ontology) = ontology else {
3328        return Vec::new();
3329    };
3330    let mut seen = BTreeSet::new();
3331    alias
3332        .split('/')
3333        .flat_map(|part| part.split('.'))
3334        .map(str::trim)
3335        .filter(|tag| !tag.is_empty())
3336        .filter_map(|tag| {
3337            let key = tag.to_ascii_lowercase();
3338            if !seen.insert(key.clone()) {
3339                return None;
3340            }
3341            let ontology_tag = ontology.tags.get(&key)?;
3342            let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
3343            Some(CompactOntologyRefPreview {
3344                handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
3345                tag: ontology_tag.tag.clone(),
3346                path,
3347                title: ontology_tag.title.clone(),
3348                domain: ontology_tag.domain.clone(),
3349            })
3350        })
3351        .collect()
3352}
3353
3354fn relativize_ontology_path(path: &Path, root: &Path) -> String {
3355    path.strip_prefix(root)
3356        .unwrap_or(path)
3357        .to_string_lossy()
3358        .replace('\\', "/")
3359}
3360
3361fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
3362    match tag_alias {
3363        Some(alias) => format!("{handle} {name} tag:{alias}"),
3364        None => format!("{handle} {name}"),
3365    }
3366}
3367
3368fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
3369    match summary.tag_alias.as_deref() {
3370        Some(alias) => format!(
3371            "{} {} tag:{} expand:{}",
3372            summary.handle, summary.symbol, alias, summary.expand
3373        ),
3374        None => format!(
3375            "{} {} expand:{}",
3376            summary.handle, summary.symbol, summary.expand
3377        ),
3378    }
3379}
3380
3381fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
3382    match symbol.tag_alias.as_deref() {
3383        Some(alias) => format!("{}@{}", symbol.handle, alias),
3384        None => format!("{}@{}", symbol.handle, symbol.name),
3385    }
3386}
3387
3388pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
3389    let trimmed = input.trim();
3390    if trimmed.len() <= max_bytes {
3391        return trimmed.to_string();
3392    }
3393    if max_bytes <= 3 {
3394        return ".".repeat(max_bytes);
3395    }
3396
3397    let mut end = 0usize;
3398    for (idx, ch) in trimmed.char_indices() {
3399        let next = idx + ch.len_utf8();
3400        if next > max_bytes.saturating_sub(3) {
3401            break;
3402        }
3403        end = next;
3404    }
3405
3406    if end == 0 {
3407        "...".to_string()
3408    } else {
3409        format!("{}...", &trimmed[..end])
3410    }
3411}
3412
3413struct TokenCappedPreview {
3414    preview: Vec<SourceLinePreview>,
3415    capped_end: usize,
3416    was_capped: bool,
3417}
3418
3419fn build_token_capped_preview(
3420    all_lines: &[&str],
3421    start: usize,
3422    end: usize,
3423    max_bytes: usize,
3424    token_cap: usize,
3425) -> TokenCappedPreview {
3426    let mut preview = Vec::new();
3427    let mut accumulated_tokens = 0usize;
3428    let mut capped_end = end;
3429    let mut was_capped = false;
3430
3431    for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
3432        let truncated = truncate_for_budget(line, max_bytes);
3433        let line_tokens = estimated_tokens_from_bytes(truncated.len());
3434        if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
3435            capped_end = start + idx - 1;
3436            was_capped = true;
3437            break;
3438        }
3439        accumulated_tokens += line_tokens;
3440        preview.push(SourceLinePreview {
3441            line: start + idx,
3442            text: truncated,
3443        });
3444    }
3445
3446    TokenCappedPreview {
3447        preview,
3448        capped_end,
3449        was_capped,
3450    }
3451}
3452
3453pub(crate) fn abbreviate_kind(kind: &str) -> &str {
3454    match kind {
3455        "function" => "fn",
3456        "method" => "meth",
3457        "module" | "mod" => "mod",
3458        "struct" => "struct",
3459        "trait" => "trait",
3460        "impl" => "impl",
3461        "class" => "cls",
3462        "interface" => "iface",
3463        "type_alias" => "type",
3464        "data_class" => "data_cls",
3465        "sealed_class" => "sealed_cls",
3466        "enum_class" => "enum_cls",
3467        "companion_object" => "comp_obj",
3468        "object" => "obj",
3469        "heading" => "h",
3470        "code_block" => "code",
3471        "alias" => "alias",
3472        other => other,
3473    }
3474}
3475
3476pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
3477    match kind {
3478        "calls" => "c",
3479        "defines" => "d",
3480        "contains" => "ct",
3481        "imports" => "i",
3482        "mentions" => "m",
3483        "mentions_concept" => "mc",
3484        "mentions_entity" => "me",
3485        "semantic_relation" => "sr",
3486        "belongs_to" => "bt",
3487        "scopes_context" => "sctx",
3488        "scopes_source" => "ssrc",
3489        "requests_context" => "rctx",
3490        "explains_result" => "er",
3491        "tagged_concept" => "tc",
3492        "tagged_entity" => "te",
3493        "related_concept" => "relc",
3494        "handled_by" => "hb",
3495        "defines_route" => "dr",
3496        "handles_route" => "hr",
3497        "targets" => "tgt",
3498        "has_vector_handle" => "hv",
3499        "parent" => "p",
3500        "child" => "ch",
3501        "uses" => "u",
3502        "projects_source" => "psrc",
3503        "records_memory_source" => "rms",
3504        "records_memory_event" => "rme",
3505        "has_ast_span" => "ha",
3506        "represents_symbol" => "rs",
3507        "contains_embedded_symbol" => "ces",
3508        "embedded_in_fence" => "ef",
3509        "contains_markdown_block" => "cmb",
3510        "contains_embedded_code" => "cec",
3511        "enclosing_module" => "em",
3512        "enclosing_section" => "es",
3513        "previous_sibling" => "psib",
3514        "next_sibling" => "nsib",
3515        "explicit_depends_on" => "edo",
3516        "worker_result_follow_up" => "wrf",
3517        "shared_resource" => "shr",
3518        "community_member" => "cm",
3519        other => other,
3520    }
3521}
3522
3523pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
3524    match mt {
3525        "exact_name" => "exact",
3526        "all_tags" => "all_tags",
3527        "partial_tags" => "partial",
3528        other => other,
3529    }
3530}
3531
3532pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
3533    path.iter()
3534        .map(|n| n.name.as_str())
3535        .collect::<Vec<_>>()
3536        .join(" -> ")
3537}
3538
3539const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
3540
3541struct SearchHitGroup {
3542    path: String,
3543    first_rank: usize,
3544    top_score: f64,
3545    confidence: String,
3546    hits: usize,
3547    samples: Vec<String>,
3548}
3549
3550fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
3551    let snippet = compact_snippet(&hit.snippet)?;
3552    Some(match hit.location.as_deref() {
3553        Some(location) => format!("{location}: {snippet}"),
3554        None => snippet,
3555    })
3556}
3557
3558pub(crate) fn group_search_hits(
3559    hits: &[sift::SearchHit],
3560    root: &Path,
3561    absolute: bool,
3562) -> Vec<SearchHitGroup> {
3563    let mut positions = BTreeMap::new();
3564    let mut groups = Vec::new();
3565    for hit in hits {
3566        let path = if absolute {
3567            hit.path.clone()
3568        } else {
3569            relativize(&hit.path, root)
3570        };
3571        let entry = positions.entry(path.clone()).or_insert_with(|| {
3572            groups.push(SearchHitGroup {
3573                path: path.clone(),
3574                first_rank: hit.rank,
3575                top_score: hit.score,
3576                confidence: format!("{:?}", hit.confidence),
3577                hits: 0,
3578                samples: Vec::new(),
3579            });
3580            groups.len() - 1
3581        });
3582        let group = &mut groups[*entry];
3583        group.hits += 1;
3584        if hit.rank < group.first_rank {
3585            group.first_rank = hit.rank;
3586        }
3587        if hit.score > group.top_score {
3588            group.top_score = hit.score;
3589        }
3590        if let Some(sample) = format_search_sample(hit)
3591            && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
3592            && !group.samples.contains(&sample)
3593        {
3594            group.samples.push(sample);
3595        }
3596    }
3597    groups.sort_by_key(|group| group.first_rank);
3598    groups
3599}
3600
3601pub(crate) fn should_collapse_search_hits(
3602    hits: &[sift::SearchHit],
3603    root: &Path,
3604    absolute: bool,
3605) -> bool {
3606    let groups = group_search_hits(hits, root, absolute);
3607    let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
3608    max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
3609}
3610
3611pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
3612    let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
3613    for edge in edges {
3614        let key = edge.caller_file.as_str();
3615        let name = if use_callers {
3616            edge.caller_name.as_str()
3617        } else {
3618            edge.callee_name.as_str()
3619        };
3620        let names = grouped.entry(key).or_default();
3621        if !names.contains(&name) {
3622            names.push(name);
3623        }
3624    }
3625
3626    grouped
3627        .into_iter()
3628        .map(|(file, names)| format!("  {} ({}): {}", file, names.len(), names.join(", ")))
3629        .collect()
3630}
3631
3632pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
3633    let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
3634    for edge in edges {
3635        *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
3636    }
3637    let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
3638    max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
3639}
3640
3641fn resolve_query_index_target(
3642    root: &Path,
3643    path_hint: &Path,
3644    scope: Option<&str>,
3645) -> Result<SearchIndexTarget> {
3646    let cfg = config::Config::load(root)?;
3647    if let Some(scope_name) = scope {
3648        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3649            return Ok(SearchIndexTarget {
3650                label: format!("submodule `{}` index", scope.id),
3651                db_path: cfg.db_path_for(root, &scope.id),
3652                source_root: scope.source_root.clone(),
3653                scope_name: Some(scope.id.clone()),
3654                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3655            });
3656        }
3657        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3658            return Ok(cargo_package_index_target(root, package));
3659        }
3660        config::Config::resolve_submodule(root, scope_name)?;
3661    }
3662
3663    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3664        return Ok(SearchIndexTarget {
3665            label: format!("submodule `{}` index", scope.id),
3666            db_path: cfg.db_path_for(root, &scope.id),
3667            source_root: scope.source_root.clone(),
3668            scope_name: Some(scope.id.clone()),
3669            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3670        });
3671    }
3672
3673    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3674        return Ok(cargo_package_index_target(root, package));
3675    }
3676
3677    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3678        return Ok(SearchIndexTarget {
3679            label: format!("submodule `{}` index", scope.id),
3680            db_path: cfg.db_path_for(root, &scope.id),
3681            source_root: scope.source_root.clone(),
3682            scope_name: Some(scope.id.clone()),
3683            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3684        });
3685    }
3686
3687    let db_path = root.join(".tsift/index.db");
3688    if db_path.exists() {
3689        return Ok(SearchIndexTarget {
3690            label: "index".to_string(),
3691            db_path,
3692            source_root: root.to_path_buf(),
3693            scope_name: None,
3694            reindex_cmd: format!("tsift index {}", root.display()),
3695        });
3696    }
3697
3698    let scopes = config::Config::submodule_dirs(root)?;
3699    if scopes.is_empty() {
3700        return Ok(SearchIndexTarget {
3701            label: "index".to_string(),
3702            db_path,
3703            source_root: root.to_path_buf(),
3704            scope_name: None,
3705            reindex_cmd: format!("tsift index {}", root.display()),
3706        });
3707    }
3708
3709    let available_scopes = scopes
3710        .iter()
3711        .map(|scope| scope.id.as_str())
3712        .collect::<Vec<_>>()
3713        .join(", ");
3714    let indexed_scopes = scopes
3715        .iter()
3716        .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3717        .map(|scope| scope.id.as_str())
3718        .collect::<Vec<_>>();
3719    let indexed_label = if indexed_scopes.is_empty() {
3720        "none".to_string()
3721    } else {
3722        indexed_scopes.join(", ")
3723    };
3724
3725    bail!(
3726        "workspace root {} has no shared root index at {}. Read-only graph queries require `--scope <scope>` when the workspace is indexed into `.tsift/indexes/*/index.db`. Available scopes: {}. Indexed scopes: {}.",
3727        root.display(),
3728        db_path.display(),
3729        available_scopes,
3730        indexed_label
3731    );
3732}
3733
3734pub(crate) fn resolve_query_db_path(
3735    root: &Path,
3736    path_hint: &Path,
3737    scope: Option<&str>,
3738) -> Result<PathBuf> {
3739    Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3740}
3741
3742fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3743    let state = inspect_search_index(target)?;
3744    let Some(reason) = index_reason_for_state(state) else {
3745        return Ok(());
3746    };
3747
3748    match apply_search_index_update(root, target) {
3749        Ok(_) => {
3750            index::inspect_scope_invalidate_all();
3751            Ok(())
3752        }
3753        Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3754            eprintln!(
3755                "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3756                 Continuing with the current read-only index snapshot; graph results may lag. \
3757                 Retry `{}` after the active writer finishes for fresh graph results.",
3758                index_reason_detail(target, reason),
3759                target.reindex_cmd
3760            );
3761            Ok(())
3762        }
3763        Err(err) => Err(err),
3764    }
3765}
3766
3767pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3768    let root = lint::resolve_project_root_or_canonical_path(path)?;
3769    let target = resolve_query_index_target(&root, path, scope)?;
3770    ensure_query_index_current(&root, &target)?;
3771    let db_path = target.db_path;
3772    if !db_path.exists() {
3773        bail!(
3774            "no index found at {}. Run `tsift index` first.",
3775            db_path.display()
3776        );
3777    }
3778    index::IndexDb::open_read_only_resilient(&db_path)
3779}
3780
3781pub(crate) fn query_tagpath_root(
3782    root: &std::path::Path,
3783    path_hint: &std::path::Path,
3784    scope: Option<&str>,
3785) -> Result<PathBuf> {
3786    if let Some(scope_name) = scope {
3787        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3788            return Ok(scope.source_root);
3789        }
3790        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3791            return Ok(package.package_root);
3792        }
3793        config::Config::resolve_submodule(root, scope_name)?;
3794    }
3795    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3796        return Ok(scope.source_root);
3797    }
3798    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3799        return Ok(package.package_root);
3800    }
3801    Ok(root.to_path_buf())
3802}
3803
3804#[derive(Clone, Debug, Serialize, PartialEq)]
3805struct TraversalNode {
3806    handle: String,
3807    kind: String,
3808    label: String,
3809    #[serde(skip_serializing_if = "Option::is_none")]
3810    ref_id: Option<String>,
3811    #[serde(skip_serializing_if = "Option::is_none")]
3812    path: Option<String>,
3813    #[serde(skip_serializing_if = "Option::is_none")]
3814    line: Option<i64>,
3815    #[serde(skip_serializing_if = "Option::is_none")]
3816    detail: Option<String>,
3817    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3818    properties: BTreeMap<String, String>,
3819    expand: String,
3820}
3821
3822#[derive(Clone, Debug, Serialize, PartialEq)]
3823struct TraversalEdge {
3824    from: String,
3825    to: String,
3826    relation: String,
3827    #[serde(skip_serializing_if = "Option::is_none")]
3828    label: Option<String>,
3829    weight: usize,
3830}
3831
3832#[derive(Clone, Debug, Default)]
3833struct TraversalGraphBuild {
3834    nodes: BTreeMap<String, TraversalNode>,
3835    edges: Vec<TraversalEdge>,
3836    edge_keys: BTreeSet<(String, String, String)>,
3837    warnings: Vec<String>,
3838}
3839
3840pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3841const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3842const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3843const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3844const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3845    "context-pack-graph-orchestration-v1";
3846const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3847const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3848const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3849const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3850const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3851const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3852const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3853
3854#[derive(Debug, Serialize, PartialEq)]
3855struct TraversalTotals {
3856    nodes: usize,
3857    edges: usize,
3858}
3859
3860#[derive(Debug, Serialize, PartialEq)]
3861struct TraversalPathReport {
3862    from: TraversalNode,
3863    to: TraversalNode,
3864    hops: usize,
3865    nodes: Vec<TraversalNode>,
3866    edges: Vec<TraversalEdge>,
3867}
3868
3869#[derive(Debug, Serialize, PartialEq)]
3870struct TraversalRecommendation {
3871    handle: String,
3872    kind: String,
3873    label: String,
3874    reason: String,
3875    score: usize,
3876    expand: String,
3877}
3878
3879#[derive(Debug, Serialize, PartialEq)]
3880struct TraversalReport {
3881    root: String,
3882    #[serde(skip_serializing_if = "Option::is_none")]
3883    scope: Option<String>,
3884    mode: String,
3885    totals: TraversalTotals,
3886    #[serde(skip_serializing_if = "Option::is_none")]
3887    query: Option<String>,
3888    #[serde(skip_serializing_if = "Option::is_none")]
3889    target: Option<String>,
3890    nodes: Vec<TraversalNode>,
3891    edges: Vec<TraversalEdge>,
3892    #[serde(skip_serializing_if = "Option::is_none")]
3893    shortest_path: Option<TraversalPathReport>,
3894    recommendations: Vec<TraversalRecommendation>,
3895    exploration: ExplorationPacket,
3896    truncated: bool,
3897    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3898    warnings: Vec<String>,
3899}
3900
3901#[derive(Debug, Serialize, PartialEq)]
3902struct SemanticRelatedReport {
3903    root: String,
3904    #[serde(skip_serializing_if = "Option::is_none")]
3905    scope: Option<String>,
3906    query: String,
3907    embedding_model: String,
3908    count: usize,
3909    items: Vec<SemanticRelatedItem>,
3910    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3911    warnings: Vec<String>,
3912}
3913
3914#[derive(Clone, Debug, Serialize, PartialEq)]
3915struct SemanticRelatedItem {
3916    handle: String,
3917    kind: String,
3918    label: String,
3919    score: f64,
3920    #[serde(skip_serializing_if = "Option::is_none")]
3921    file_path: Option<String>,
3922    #[serde(skip_serializing_if = "Option::is_none")]
3923    source_symbol: Option<String>,
3924    #[serde(skip_serializing_if = "Option::is_none")]
3925    detail: Option<String>,
3926    expand: String,
3927}
3928
3929#[derive(Clone)]
3930struct TraversalSymbolIndexEntry {
3931    handle: String,
3932    node: TraversalNode,
3933    tokens: BTreeSet<String>,
3934}
3935
3936#[derive(Clone)]
3937struct TraversalFileIndexEntry {
3938    handle: String,
3939    node: TraversalNode,
3940    tokens: BTreeSet<String>,
3941}
3942
3943#[derive(Clone)]
3944struct TraversalRouteIndexEntry {
3945    handle: String,
3946    node: TraversalNode,
3947    tokens: BTreeSet<String>,
3948}
3949
3950#[derive(Clone)]
3951struct TraversalAstSpanIndexEntry {
3952    handle: String,
3953    symbol_handle: String,
3954    file_handle: Option<String>,
3955    file: String,
3956    name: String,
3957    kind: String,
3958    language: String,
3959    node_kind: String,
3960    start_byte: usize,
3961    end_byte: usize,
3962    parent_module: Option<String>,
3963    markdown: Option<MarkdownSpanMetadata>,
3964}
3965
3966#[derive(Clone)]
3967struct TraversalMultiplicityIndexEntry {
3968    handle: String,
3969    node: TraversalNode,
3970    tokens: BTreeSet<String>,
3971}
3972
3973struct TraversalCodeLookup<'a> {
3974    symbols: &'a [TraversalSymbolIndexEntry],
3975    files: &'a [TraversalFileIndexEntry],
3976    routes: &'a [TraversalRouteIndexEntry],
3977    multiplicities: &'a [TraversalMultiplicityIndexEntry],
3978    symbol_index: HashMap<String, Vec<usize>>,
3979    file_index: HashMap<String, Vec<usize>>,
3980    route_index: HashMap<String, Vec<usize>>,
3981    multiplicity_index: HashMap<String, Vec<usize>>,
3982    file_path_index: HashMap<String, String>,
3983}
3984
3985#[derive(Clone, Debug, Serialize, PartialEq)]
3986struct ExplorationBudget {
3987    project_size: String,
3988    max_source_windows: usize,
3989    lines_per_window: usize,
3990    relationship_limit: usize,
3991}
3992
3993#[derive(Clone, Debug, Serialize, PartialEq)]
3994struct ExplorationRelation {
3995    from: String,
3996    relation: String,
3997    to: String,
3998    #[serde(skip_serializing_if = "Option::is_none")]
3999    label: Option<String>,
4000}
4001
4002#[derive(Clone, Debug, Serialize, PartialEq)]
4003struct ExplorationSourceWindow {
4004    handle: String,
4005    file: String,
4006    start: usize,
4007    end: usize,
4008    reason: String,
4009    expand: String,
4010}
4011
4012#[derive(Clone, Debug, Serialize, PartialEq)]
4013struct ExplorationWorkerContext {
4014    handle: String,
4015    target: String,
4016    summary: String,
4017    expand: String,
4018}
4019
4020#[derive(Clone, Debug, Serialize, PartialEq)]
4021struct ExplorationPacket {
4022    budget: ExplorationBudget,
4023    relationship_map: Vec<ExplorationRelation>,
4024    source_windows: Vec<ExplorationSourceWindow>,
4025    #[serde(skip_serializing_if = "Vec::is_empty", default)]
4026    worker_context: Vec<ExplorationWorkerContext>,
4027    no_reread_guidance: String,
4028}
4029
4030impl TraversalGraphBuild {
4031    fn add_node(&mut self, node: TraversalNode) {
4032        self.nodes.entry(node.handle.clone()).or_insert(node);
4033    }
4034
4035    fn add_edge(
4036        &mut self,
4037        from: &str,
4038        to: &str,
4039        relation: &str,
4040        label: Option<String>,
4041        weight: usize,
4042    ) {
4043        if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
4044            return;
4045        }
4046        let key = (from.to_string(), to.to_string(), relation.to_string());
4047        if self.edge_keys.insert(key) {
4048            self.edges.push(TraversalEdge {
4049                from: from.to_string(),
4050                to: to.to_string(),
4051                relation: relation.to_string(),
4052                label,
4053                weight,
4054            });
4055        }
4056    }
4057}
4058
4059pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
4060    match scope {
4061        Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
4062        None => root.join(".tsift/graph.db"),
4063    }
4064}
4065
4066fn graph_projection_meta_id(scope: Option<&str>) -> String {
4067    format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
4068}
4069
4070pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
4071    let bytes = serde_json::to_vec(value)?;
4072    Ok(blake3::hash(&bytes).to_hex().to_string())
4073}
4074
4075fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
4076    let mut hashable = node.clone();
4077    hashable.freshness = None;
4078    node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4079    Ok(node)
4080}
4081
4082fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
4083    let mut hashable = edge.clone();
4084    hashable.freshness = None;
4085    edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4086    Ok(edge)
4087}
4088
4089const SEMANTIC_EMBEDDING_DIM: usize = 32;
4090const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
4091
4092fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
4093    match kind {
4094        SemanticRelatedKind::Concept => "concept",
4095        SemanticRelatedKind::Entity => "entity",
4096        SemanticRelatedKind::All => "all",
4097    }
4098}
4099
4100fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
4101    format!(
4102        "tsift semantic {} --path {} --kind {} --limit 10",
4103        shell_quote(query),
4104        shell_quote(root.to_string_lossy().as_ref()),
4105        semantic_related_kind_name(kind)
4106    )
4107}
4108
4109fn semantic_embedding(input: &str) -> Vec<f64> {
4110    let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
4111    let mut tokens = traversal_tokens(input);
4112    if tokens.is_empty() {
4113        let trimmed = input.trim().to_ascii_lowercase();
4114        if !trimmed.is_empty() {
4115            tokens.insert(trimmed);
4116        }
4117    }
4118
4119    for token in tokens {
4120        let hash = blake3::hash(token.as_bytes());
4121        let bytes = hash.as_bytes();
4122        let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
4123        let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
4124        vector[idx] += sign;
4125    }
4126
4127    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
4128    if norm > 0.0 {
4129        for value in &mut vector {
4130            *value /= norm;
4131        }
4132    }
4133    vector
4134}
4135
4136fn semantic_embedding_property(input: &str) -> String {
4137    semantic_embedding(input)
4138        .iter()
4139        .map(|value| format!("{value:.6}"))
4140        .collect::<Vec<_>>()
4141        .join(",")
4142}
4143
4144fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
4145    let parsed = value
4146        .split(',')
4147        .map(str::trim)
4148        .map(str::parse::<f64>)
4149        .collect::<std::result::Result<Vec<_>, _>>()
4150        .ok()?;
4151    (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
4152}
4153
4154fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
4155    if left.len() != right.len() {
4156        return 0.0;
4157    }
4158    left.iter()
4159        .zip(right.iter())
4160        .map(|(left, right)| left * right)
4161        .sum::<f64>()
4162}
4163
4164fn semantic_entity_handle(name: &str, kind: &str) -> String {
4165    stable_handle(
4166        "gent",
4167        &format!(
4168            "entity:{}:{}",
4169            kind.trim().to_ascii_lowercase(),
4170            name.trim().to_ascii_lowercase()
4171        ),
4172    )
4173}
4174
4175fn semantic_concept_handle(label: &str) -> String {
4176    stable_handle(
4177        "gcon",
4178        &format!("concept:{}", label.trim().to_ascii_lowercase()),
4179    )
4180}
4181
4182fn summary_source_handles(
4183    summary: &summarize::Summary,
4184    file_node_by_path: &BTreeMap<String, String>,
4185    symbol_node_by_file_label: &BTreeMap<(String, String), String>,
4186) -> Vec<String> {
4187    let mut handles = Vec::new();
4188    if let Some(handle) = file_node_by_path.get(&summary.file_path) {
4189        handles.push(handle.clone());
4190    }
4191    if let Some(handle) =
4192        symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
4193        && !handles.iter().any(|existing| existing == handle)
4194    {
4195        handles.push(handle.clone());
4196    }
4197    handles
4198}
4199
4200fn semantic_entity_node(
4201    root: &Path,
4202    summary: &summarize::Summary,
4203    name: &str,
4204    kind: &str,
4205    description: &str,
4206    provenance: &GraphProvenance,
4207) -> SubstrateGraphNode {
4208    let handle = semantic_entity_handle(name, kind);
4209    let detail = if description.trim().is_empty() {
4210        format!("{kind} entity from cached summaries")
4211    } else {
4212        format!("{kind}: {description}")
4213    };
4214    SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
4215        .with_property("handle", handle)
4216        .with_property("ref_id", name.to_string())
4217        .with_property("detail", detail)
4218        .with_property("entity_kind", kind.to_string())
4219        .with_property("description", description.to_string())
4220        .with_property("source_file", summary.file_path.clone())
4221        .with_property("source_symbol", summary.symbol_name.clone())
4222        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4223        .with_property(
4224            "embedding",
4225            semantic_embedding_property(&format!("{name} {kind} {description}")),
4226        )
4227        .with_property(
4228            "expand",
4229            semantic_related_command(root, name, SemanticRelatedKind::Entity),
4230        )
4231        .with_provenance(provenance.clone())
4232}
4233
4234fn semantic_concept_node(
4235    root: &Path,
4236    summary: &summarize::Summary,
4237    label: &str,
4238    provenance: &GraphProvenance,
4239) -> SubstrateGraphNode {
4240    let handle = semantic_concept_handle(label);
4241    SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
4242        .with_property("handle", handle)
4243        .with_property("ref_id", label.to_string())
4244        .with_property("detail", "concept label from cached summaries".to_string())
4245        .with_property("source_file", summary.file_path.clone())
4246        .with_property("source_symbol", summary.symbol_name.clone())
4247        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4248        .with_property("embedding", semantic_embedding_property(label))
4249        .with_property(
4250            "expand",
4251            semantic_related_command(root, label, SemanticRelatedKind::Concept),
4252        )
4253        .with_provenance(provenance.clone())
4254}
4255
4256fn insert_semantic_edge(
4257    edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
4258    edge: SubstrateGraphEdge,
4259) {
4260    edge_map
4261        .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
4262        .or_insert(edge);
4263}
4264
4265fn append_summary_semantic_projection_rows(
4266    root: &Path,
4267    graph: &TraversalGraphBuild,
4268    provenance: &GraphProvenance,
4269    nodes: &mut Vec<SubstrateGraphNode>,
4270    edges: &mut Vec<SubstrateGraphEdge>,
4271) -> Result<()> {
4272    let summaries_db = root.join(".tsift/summaries.db");
4273    if !summaries_db.exists() {
4274        return Ok(());
4275    }
4276
4277    let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
4278    let summaries = summary_db.all()?;
4279    if summaries.is_empty() {
4280        return Ok(());
4281    }
4282
4283    let file_node_by_path = graph
4284        .nodes
4285        .values()
4286        .filter(|node| node.kind == "file")
4287        .filter_map(|node| {
4288            node.path
4289                .as_ref()
4290                .map(|path| (path.clone(), node.handle.clone()))
4291        })
4292        .collect::<BTreeMap<_, _>>();
4293    let symbol_node_by_file_label = graph
4294        .nodes
4295        .values()
4296        .filter(|node| node.kind == "symbol")
4297        .filter_map(|node| {
4298            Some((
4299                (node.path.clone()?, node.label.clone()),
4300                node.handle.clone(),
4301            ))
4302        })
4303        .collect::<BTreeMap<_, _>>();
4304
4305    let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
4306    let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
4307
4308    for summary in &summaries {
4309        let source_handles =
4310            summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
4311        let mut entity_ids_by_name = BTreeMap::<String, String>::new();
4312
4313        if let Some(entities) = &summary.entities {
4314            for entity in entities {
4315                let node = semantic_entity_node(
4316                    root,
4317                    summary,
4318                    &entity.name,
4319                    &entity.kind,
4320                    &entity.description,
4321                    provenance,
4322                );
4323                let entity_id = node.id.clone();
4324                entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
4325                semantic_nodes.entry(entity_id.clone()).or_insert(node);
4326
4327                for source_handle in &source_handles {
4328                    insert_semantic_edge(
4329                        &mut semantic_edges,
4330                        SubstrateGraphEdge::new(
4331                            source_handle.clone(),
4332                            entity_id.clone(),
4333                            "mentions_entity",
4334                        )
4335                        .with_property("label", format!("summary entity: {}", entity.name))
4336                        .with_property("source_file", summary.file_path.clone())
4337                        .with_provenance(provenance.clone()),
4338                    );
4339                }
4340            }
4341        }
4342
4343        let mut concept_ids = Vec::new();
4344        if let Some(labels) = &summary.concept_labels {
4345            for label in labels
4346                .iter()
4347                .map(|label| label.trim())
4348                .filter(|label| !label.is_empty())
4349            {
4350                let node = semantic_concept_node(root, summary, label, provenance);
4351                let concept_id = node.id.clone();
4352                semantic_nodes.entry(concept_id.clone()).or_insert(node);
4353                concept_ids.push(concept_id.clone());
4354
4355                for source_handle in &source_handles {
4356                    insert_semantic_edge(
4357                        &mut semantic_edges,
4358                        SubstrateGraphEdge::new(
4359                            source_handle.clone(),
4360                            concept_id.clone(),
4361                            "mentions_concept",
4362                        )
4363                        .with_property("label", format!("summary concept: {label}"))
4364                        .with_property("source_file", summary.file_path.clone())
4365                        .with_provenance(provenance.clone()),
4366                    );
4367                }
4368            }
4369        }
4370
4371        for entity_id in entity_ids_by_name.values() {
4372            for concept_id in &concept_ids {
4373                insert_semantic_edge(
4374                    &mut semantic_edges,
4375                    SubstrateGraphEdge::new(
4376                        entity_id.clone(),
4377                        concept_id.clone(),
4378                        "tagged_concept",
4379                    )
4380                    .with_property("label", "entity concept label".to_string())
4381                    .with_property("source_file", summary.file_path.clone())
4382                    .with_provenance(provenance.clone()),
4383                );
4384            }
4385        }
4386
4387        for idx in 0..concept_ids.len() {
4388            for next_idx in (idx + 1)..concept_ids.len() {
4389                insert_semantic_edge(
4390                    &mut semantic_edges,
4391                    SubstrateGraphEdge::new(
4392                        concept_ids[idx].clone(),
4393                        concept_ids[next_idx].clone(),
4394                        "related_concept",
4395                    )
4396                    .with_property("label", format!("co-occurs in {}", summary.symbol_name))
4397                    .with_property("source_file", summary.file_path.clone())
4398                    .with_provenance(provenance.clone()),
4399                );
4400            }
4401        }
4402
4403        if let Some(relationships) = &summary.relationships {
4404            for relationship in relationships {
4405                let from_id = entity_ids_by_name
4406                    .get(&relationship.from.to_ascii_lowercase())
4407                    .cloned()
4408                    .unwrap_or_else(|| {
4409                        let node = semantic_entity_node(
4410                            root,
4411                            summary,
4412                            &relationship.from,
4413                            "unknown",
4414                            "",
4415                            provenance,
4416                        );
4417                        let id = node.id.clone();
4418                        semantic_nodes.entry(id.clone()).or_insert(node);
4419                        id
4420                    });
4421                let to_id = entity_ids_by_name
4422                    .get(&relationship.to.to_ascii_lowercase())
4423                    .cloned()
4424                    .unwrap_or_else(|| {
4425                        let node = semantic_entity_node(
4426                            root,
4427                            summary,
4428                            &relationship.to,
4429                            "unknown",
4430                            "",
4431                            provenance,
4432                        );
4433                        let id = node.id.clone();
4434                        semantic_nodes.entry(id.clone()).or_insert(node);
4435                        id
4436                    });
4437                insert_semantic_edge(
4438                    &mut semantic_edges,
4439                    SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
4440                        .with_property("relationship_kind", relationship.kind.clone())
4441                        .with_property("label", relationship.kind.clone())
4442                        .with_property("source_file", summary.file_path.clone())
4443                        .with_property("source_symbol", summary.symbol_name.clone())
4444                        .with_provenance(provenance.clone()),
4445                );
4446            }
4447        }
4448    }
4449
4450    for node in semantic_nodes.into_values() {
4451        nodes.push(node_with_content_freshness(node)?);
4452    }
4453    for edge in semantic_edges.into_values() {
4454        edges.push(edge_with_content_freshness(edge)?);
4455    }
4456
4457    Ok(())
4458}
4459
4460fn projection_content_hash(
4461    nodes: &[SubstrateGraphNode],
4462    edges: &[SubstrateGraphEdge],
4463) -> Result<String> {
4464    #[derive(Serialize)]
4465    struct Payload<'a> {
4466        version: &'static str,
4467        nodes: &'a [SubstrateGraphNode],
4468        edges: &'a [SubstrateGraphEdge],
4469    }
4470
4471    content_hash(&Payload {
4472        version: GRAPH_PROJECTION_VERSION,
4473        nodes,
4474        edges,
4475    })
4476}
4477
4478pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
4479    projection
4480        .nodes
4481        .iter()
4482        .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
4483        .and_then(|node| node.properties.get("content_hash").cloned())
4484}
4485
4486fn traversal_projection_from_graph(
4487    root: &Path,
4488    scope: Option<&str>,
4489    graph: &TraversalGraphBuild,
4490) -> Result<GraphProjection> {
4491    let provenance = GraphProvenance::new(
4492        "tsift.traverse",
4493        format!("{}:{}", root.display(), scope.unwrap_or("root")),
4494    );
4495    let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
4496    for node in graph.nodes.values() {
4497        let mut projected =
4498            SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
4499                .with_property("handle", node.handle.clone())
4500                .with_property("expand", node.expand.clone())
4501                .with_provenance(provenance.clone());
4502        if let Some(ref_id) = &node.ref_id {
4503            projected = projected.with_property("ref_id", ref_id.clone());
4504        }
4505        if let Some(path) = &node.path {
4506            projected = projected.with_property("path", path.clone());
4507        }
4508        if let Some(line) = node.line {
4509            projected = projected.with_property("line", line.to_string());
4510        }
4511        if let Some(detail) = &node.detail {
4512            projected = projected.with_property("detail", detail.clone());
4513        }
4514        for (key, value) in &node.properties {
4515            projected = projected.with_property(key.clone(), value.clone());
4516        }
4517        nodes.push(node_with_content_freshness(projected)?);
4518    }
4519
4520    let mut edges = Vec::with_capacity(graph.edges.len());
4521    for edge in &graph.edges {
4522        let mut projected =
4523            SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
4524                .with_property("weight", edge.weight.to_string())
4525                .with_provenance(provenance.clone());
4526        if let Some(label) = &edge.label {
4527            projected = projected.with_property("label", label.clone());
4528        }
4529        edges.push(edge_with_content_freshness(projected)?);
4530    }
4531
4532    append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4533    append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4534    append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
4535
4536    let projection_hash = projection_content_hash(&nodes, &edges)?;
4537    let meta = SubstrateGraphNode::new(
4538        graph_projection_meta_id(scope),
4539        GRAPH_PROJECTION_META_KIND,
4540        "tsift traversal projection",
4541    )
4542    .with_property("projection_version", GRAPH_PROJECTION_VERSION)
4543    .with_property("content_hash", projection_hash.clone())
4544    .with_property("root", root.to_string_lossy().to_string())
4545    .with_property("scope", scope.unwrap_or("root"))
4546    .with_property("node_count", graph.nodes.len().to_string())
4547    .with_property("edge_count", graph.edges.len().to_string())
4548    .with_provenance(provenance)
4549    .with_freshness(GraphFreshness::content_hash(projection_hash));
4550    nodes.push(meta);
4551
4552    Ok(GraphProjection { nodes, edges })
4553}
4554
4555#[allow(clippy::too_many_arguments)]
4556fn ensure_traversal_source_handle(
4557    root: &Path,
4558    provenance: &GraphProvenance,
4559    file_node_by_path: &BTreeMap<String, String>,
4560    node: &TraversalNode,
4561    budget: &ExplorationBudget,
4562    source_handle_by_node: &mut BTreeMap<String, String>,
4563    seen_windows: &mut BTreeMap<(String, usize, usize), String>,
4564    nodes: &mut Vec<SubstrateGraphNode>,
4565    edges: &mut Vec<SubstrateGraphEdge>,
4566) -> Result<Option<String>> {
4567    if let Some(handle) = source_handle_by_node.get(&node.handle) {
4568        return Ok(Some(handle.clone()));
4569    }
4570    let Some(window) = exploration_source_window_for_node(root, node, budget) else {
4571        return Ok(None);
4572    };
4573    let window_key = (window.file.clone(), window.start, window.end);
4574    let handle = if let Some(handle) = seen_windows.get(&window_key) {
4575        handle.clone()
4576    } else {
4577        let label = format!("{}:{}-{}", window.file, window.start, window.end);
4578        let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
4579            .with_property("handle", window.handle.clone())
4580            .with_property("file", window.file.clone())
4581            .with_property("start", window.start.to_string())
4582            .with_property("end", window.end.to_string())
4583            .with_property("reason", window.reason.clone())
4584            .with_property("expand", window.expand.clone())
4585            .with_provenance(provenance.clone());
4586        nodes.push(node_with_content_freshness(projected)?);
4587
4588        if let Some(file_handle) = file_node_by_path.get(&window.file) {
4589            let edge = SubstrateGraphEdge::new(
4590                window.handle.clone(),
4591                file_handle.clone(),
4592                "expands_source",
4593            )
4594            .with_property("label", window.reason.clone())
4595            .with_provenance(provenance.clone());
4596            edges.push(edge_with_content_freshness(edge)?);
4597        }
4598        if node.kind != "file" {
4599            let edge = SubstrateGraphEdge::new(
4600                window.handle.clone(),
4601                node.handle.clone(),
4602                "anchors_source",
4603            )
4604            .with_property("label", window.reason.clone())
4605            .with_provenance(provenance.clone());
4606            edges.push(edge_with_content_freshness(edge)?);
4607        }
4608        seen_windows.insert(window_key, window.handle.clone());
4609        window.handle
4610    };
4611    source_handle_by_node.insert(node.handle.clone(), handle.clone());
4612    Ok(Some(handle))
4613}
4614
4615fn push_traversal_backlog_target_handles<'a>(
4616    backlog: &TraversalNode,
4617    edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
4618    node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
4619    max_handles: usize,
4620    seen_target_nodes: &mut BTreeSet<String>,
4621    target_node_handles: &mut Vec<String>,
4622) {
4623    for edge in edges_by_from
4624        .get(backlog.handle.as_str())
4625        .into_iter()
4626        .flatten()
4627        .filter(|edge| edge.relation == "mentions")
4628    {
4629        let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
4630            continue;
4631        };
4632        if !matches!(
4633            target_node.kind.as_str(),
4634            "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
4635        ) {
4636            continue;
4637        }
4638        if target_node
4639            .path
4640            .as_deref()
4641            .zip(backlog.path.as_deref())
4642            .is_some_and(|(target_path, backlog_path)| {
4643                target_path == backlog_path && target_path.ends_with(".md")
4644            })
4645        {
4646            continue;
4647        }
4648        if seen_target_nodes.insert(target_node.handle.clone()) {
4649            target_node_handles.push(target_node.handle.clone());
4650        }
4651        if target_node_handles.len() >= max_handles {
4652            break;
4653        }
4654    }
4655}
4656
4657fn append_traversal_context_projection_rows(
4658    root: &Path,
4659    graph: &TraversalGraphBuild,
4660    provenance: &GraphProvenance,
4661    nodes: &mut Vec<SubstrateGraphNode>,
4662    edges: &mut Vec<SubstrateGraphEdge>,
4663) -> Result<()> {
4664    let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4665    let file_node_by_path = graph
4666        .nodes
4667        .values()
4668        .filter(|node| node.kind == "file")
4669        .filter_map(|node| {
4670            node.path
4671                .as_ref()
4672                .map(|path| (path.clone(), node.handle.clone()))
4673        })
4674        .collect::<BTreeMap<_, _>>();
4675
4676    let node_by_handle = graph
4677        .nodes
4678        .values()
4679        .map(|node| (node.handle.as_str(), node))
4680        .collect::<BTreeMap<_, _>>();
4681    let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4682    for edge in &graph.edges {
4683        edges_by_from
4684            .entry(edge.from.as_str())
4685            .or_default()
4686            .push(edge);
4687    }
4688    for rows in edges_by_from.values_mut() {
4689        rows.sort_by(|left, right| {
4690            right
4691                .weight
4692                .cmp(&left.weight)
4693                .then(left.relation.cmp(&right.relation))
4694                .then(left.to.cmp(&right.to))
4695        });
4696    }
4697
4698    let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4699    let mut source_handle_by_node = BTreeMap::<String, String>::new();
4700
4701    let mut code_context_count = 0usize;
4702    let code_context_limit = budget.relationship_limit.min(8);
4703    for node in graph.nodes.values() {
4704        if !matches!(
4705            node.kind.as_str(),
4706            "backlog" | "job_packet" | "worker_result"
4707        ) {
4708            continue;
4709        }
4710        let mut target_node_handles = Vec::new();
4711        let mut fallback_target_handles = Vec::new();
4712        let mut seen_target_nodes = BTreeSet::new();
4713        if node.kind == "backlog" || node.kind == "worker_result" {
4714            push_traversal_backlog_target_handles(
4715                node,
4716                &edges_by_from,
4717                &node_by_handle,
4718                budget.max_source_windows,
4719                &mut seen_target_nodes,
4720                &mut target_node_handles,
4721            );
4722            fallback_target_handles.push(node.handle.clone());
4723        } else {
4724            for edge in edges_by_from
4725                .get(node.handle.as_str())
4726                .into_iter()
4727                .flatten()
4728                .filter(|edge| edge.relation == "targets")
4729            {
4730                let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4731                    continue;
4732                };
4733                fallback_target_handles.push(backlog.handle.clone());
4734                push_traversal_backlog_target_handles(
4735                    backlog,
4736                    &edges_by_from,
4737                    &node_by_handle,
4738                    budget.max_source_windows,
4739                    &mut seen_target_nodes,
4740                    &mut target_node_handles,
4741                );
4742                if target_node_handles.len() >= budget.max_source_windows {
4743                    break;
4744                }
4745            }
4746            if fallback_target_handles.is_empty() {
4747                continue;
4748            }
4749        }
4750        let code_context = !target_node_handles.is_empty();
4751        if target_node_handles.is_empty() {
4752            target_node_handles = dedupe_preserve_order(fallback_target_handles);
4753        } else if code_context_count >= code_context_limit {
4754            continue;
4755        }
4756
4757        let mut worker_source_handles = Vec::new();
4758        let mut seen_worker_handles = BTreeSet::new();
4759        for target_handle in target_node_handles {
4760            if worker_source_handles.len() >= budget.max_source_windows {
4761                break;
4762            }
4763            let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4764                continue;
4765            };
4766            let Some(handle) = ensure_traversal_source_handle(
4767                root,
4768                provenance,
4769                &file_node_by_path,
4770                target_node,
4771                &budget,
4772                &mut source_handle_by_node,
4773                &mut seen_windows,
4774                nodes,
4775                edges,
4776            )?
4777            else {
4778                continue;
4779            };
4780            if seen_worker_handles.insert(handle.clone()) {
4781                worker_source_handles.push(handle);
4782            }
4783        }
4784        if worker_source_handles.is_empty() {
4785            continue;
4786        }
4787        let target = node
4788            .path
4789            .clone()
4790            .unwrap_or_else(|| root.to_string_lossy().to_string());
4791        let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4792        let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4793        let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4794            .with_property("handle", handle.clone())
4795            .with_property("target", target.clone())
4796            .with_property("summary", summary)
4797            .with_property(
4798                "source_handle_count",
4799                worker_source_handles.len().to_string(),
4800            )
4801            .with_property(
4802                "expand",
4803                format!(
4804                    "tsift --envelope context-pack {} --budget normal",
4805                    shell_quote(&target)
4806                ),
4807            )
4808            .with_provenance(provenance.clone());
4809        nodes.push(node_with_content_freshness(projected)?);
4810
4811        let request_edge =
4812            SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4813                .with_property("label", "bounded worker context".to_string())
4814                .with_provenance(provenance.clone());
4815        edges.push(edge_with_content_freshness(request_edge)?);
4816
4817        for source_handle in &worker_source_handles {
4818            let scope_edge =
4819                SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4820                    .with_property("label", "bounded worker source window".to_string())
4821                    .with_provenance(provenance.clone());
4822            edges.push(edge_with_content_freshness(scope_edge)?);
4823        }
4824        if code_context {
4825            code_context_count += 1;
4826        }
4827    }
4828
4829    Ok(())
4830}
4831
4832fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4833    let handle = node
4834        .properties
4835        .get("handle")
4836        .cloned()
4837        .unwrap_or_else(|| node.id.clone());
4838    TraversalNode {
4839        expand: node
4840            .properties
4841            .get("expand")
4842            .cloned()
4843            .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4844        handle,
4845        kind: node.kind,
4846        label: node.label,
4847        ref_id: node.properties.get("ref_id").cloned(),
4848        path: node.properties.get("path").cloned(),
4849        line: node
4850            .properties
4851            .get("line")
4852            .and_then(|value| value.parse::<i64>().ok()),
4853        detail: node.properties.get("detail").cloned(),
4854        properties: node.properties,
4855    }
4856}
4857
4858fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4859    let mut graph = TraversalGraphBuild::default();
4860    for node in store.all_nodes()? {
4861        if node.kind == GRAPH_PROJECTION_META_KIND {
4862            continue;
4863        }
4864        graph.add_node(traversal_node_from_graph_node(root, node));
4865    }
4866    for edge in store.all_edges()? {
4867        graph.add_edge(
4868            &edge.from_id,
4869            &edge.to_id,
4870            &edge.kind,
4871            edge.properties.get("label").cloned(),
4872            edge.properties
4873                .get("weight")
4874                .and_then(|value| value.parse::<usize>().ok())
4875                .unwrap_or(1),
4876        );
4877    }
4878    Ok(graph)
4879}
4880
4881pub(crate) fn convex_rows_from_graph_store(
4882    store: &impl GraphStore,
4883) -> Result<ConvexProjectionRows> {
4884    Ok(GraphProjection {
4885        nodes: store.all_nodes()?,
4886        edges: store.all_edges()?,
4887    }
4888    .to_convex_rows())
4889}
4890
4891#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4892struct ConvexRequiredIndex {
4893    table: String,
4894    name: String,
4895    fields: Vec<String>,
4896}
4897
4898#[derive(Clone, Debug, Serialize, PartialEq)]
4899struct ConvexSyncChunk {
4900    operation: String,
4901    chunk: usize,
4902    count: usize,
4903    keys: Vec<String>,
4904    max_attempts: usize,
4905    retry_policy: String,
4906}
4907
4908#[derive(Clone, Debug, Serialize, PartialEq)]
4909struct ConvexTransportSummary {
4910    endpoint_env: String,
4911    endpoint_configured: bool,
4912    auth_token_env: String,
4913    auth_configured: bool,
4914    remote_snapshot: bool,
4915    applied_chunks: usize,
4916}
4917
4918#[derive(Clone, Debug, Serialize, PartialEq)]
4919struct ConvexTransportReceipt {
4920    operation: String,
4921    chunk: usize,
4922    attempt: usize,
4923    status: String,
4924    message: Option<String>,
4925}
4926
4927#[derive(Serialize)]
4928#[serde(rename_all = "camelCase")]
4929struct ConvexTransportRequest<'a> {
4930    operation: &'a str,
4931    chunk: usize,
4932    projection_version: &'a str,
4933    projection_hash: Option<&'a str>,
4934    #[serde(skip_serializing_if = "Option::is_none")]
4935    projection_meta_id: Option<&'a str>,
4936    node_rows: Vec<ConvexNodeRow>,
4937    edge_rows: Vec<ConvexEdgeRow>,
4938    keys: Vec<String>,
4939    #[serde(skip_serializing_if = "Option::is_none")]
4940    cursor: Option<String>,
4941    #[serde(skip_serializing_if = "Option::is_none")]
4942    limit: Option<usize>,
4943}
4944
4945#[derive(Deserialize)]
4946#[serde(rename_all = "camelCase")]
4947struct ConvexTransportResponse {
4948    status: Option<String>,
4949    message: Option<String>,
4950    rows: Option<ConvexProjectionRows>,
4951    #[serde(default)]
4952    meta: Option<ConvexSnapshotMeta>,
4953    #[serde(default)]
4954    page: Option<ConvexSnapshotPage>,
4955}
4956
4957#[derive(Deserialize, Debug, Clone)]
4958#[serde(rename_all = "camelCase")]
4959struct ConvexSnapshotMeta {
4960    // Captured for completeness/debugging; not currently consumed by the
4961    // freshness diff (indexes are already validated against the required set
4962    // via `convex_required_indexes`, and `page_size` is informational only).
4963    #[serde(default)]
4964    #[allow(dead_code)]
4965    indexes: Vec<ConvexRequiredIndex>,
4966    #[serde(default)]
4967    #[allow(dead_code)]
4968    node_count: Option<usize>,
4969    #[serde(default)]
4970    #[allow(dead_code)]
4971    edge_count: Option<usize>,
4972    #[serde(default)]
4973    projection_hash: Option<String>,
4974    #[serde(default)]
4975    #[allow(dead_code)]
4976    page_size: Option<usize>,
4977}
4978
4979/// Paginated snapshot page response. `rows` is either node rows or edge rows
4980/// depending on which operation was called; we deserialize as raw values to
4981/// keep the transport struct shared between both shapes, then narrow per call
4982/// site.
4983#[derive(Deserialize, Debug, Clone)]
4984#[serde(rename_all = "camelCase")]
4985struct ConvexSnapshotPage {
4986    rows: Vec<serde_json::Value>,
4987    #[serde(default)]
4988    next_cursor: Option<String>,
4989}
4990
4991#[derive(Clone, Debug, Serialize, PartialEq)]
4992struct ConvexProjectionFreshness {
4993    status: String,
4994    fail_closed: bool,
4995    local_hash: Option<String>,
4996    snapshot_hash: Option<String>,
4997    missing_nodes: Vec<String>,
4998    stale_nodes: Vec<String>,
4999    missing_edges: Vec<String>,
5000    stale_edges: Vec<String>,
5001    diagnostics: Vec<String>,
5002}
5003
5004const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
5005
5006impl ConvexProjectionFreshness {
5007    fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
5008        Self {
5009            status: "current".to_string(),
5010            fail_closed: false,
5011            local_hash,
5012            snapshot_hash,
5013            missing_nodes: Vec::new(),
5014            stale_nodes: Vec::new(),
5015            missing_edges: Vec::new(),
5016            stale_edges: Vec::new(),
5017            diagnostics: Vec::new(),
5018        }
5019    }
5020}
5021
5022#[derive(Clone, Debug, Serialize, PartialEq)]
5023struct ConvexSyncReport {
5024    root: String,
5025    #[serde(skip_serializing_if = "Option::is_none")]
5026    scope: Option<String>,
5027    graph_db: String,
5028    dry_run: bool,
5029    projection_version: String,
5030    projection_hash: Option<String>,
5031    required_indexes: Vec<ConvexRequiredIndex>,
5032    node_upserts: Vec<ConvexNodeRow>,
5033    edge_upserts: Vec<ConvexEdgeRow>,
5034    node_tombstones: Vec<String>,
5035    edge_tombstones: Vec<String>,
5036    chunks: Vec<ConvexSyncChunk>,
5037    freshness: ConvexProjectionFreshness,
5038    transport: Option<ConvexTransportSummary>,
5039    receipts: Vec<ConvexTransportReceipt>,
5040    diagnostics: Vec<String>,
5041    warnings: Vec<String>,
5042}
5043
5044fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
5045    vec![
5046        ConvexRequiredIndex {
5047            table: "nodes".to_string(),
5048            name: "by_external_id".to_string(),
5049            fields: vec!["externalId".to_string()],
5050        },
5051        ConvexRequiredIndex {
5052            table: "nodes".to_string(),
5053            name: "by_kind".to_string(),
5054            fields: vec!["kind".to_string()],
5055        },
5056        ConvexRequiredIndex {
5057            table: "edges".to_string(),
5058            name: "by_edge_key".to_string(),
5059            fields: vec!["edgeKey".to_string()],
5060        },
5061        ConvexRequiredIndex {
5062            table: "edges".to_string(),
5063            name: "by_from_kind".to_string(),
5064            fields: vec!["fromExternalId".to_string(), "kind".to_string()],
5065        },
5066        ConvexRequiredIndex {
5067            table: "edges".to_string(),
5068            name: "by_to_kind".to_string(),
5069            fields: vec!["toExternalId".to_string(), "kind".to_string()],
5070        },
5071    ]
5072}
5073
5074pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
5075    let content = fs::read_to_string(path)
5076        .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
5077    serde_json::from_str(&content)
5078        .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
5079}
5080
5081fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
5082    let mut diagnostics = Vec::new();
5083    let mut node_counts = BTreeMap::<&str, usize>::new();
5084    for row in &rows.nodes {
5085        *node_counts.entry(row.external_id.as_str()).or_default() += 1;
5086    }
5087    for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
5088        diagnostics.push(format!(
5089            "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
5090        ));
5091    }
5092
5093    let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
5094    let mut edge_counts = BTreeMap::<&str, usize>::new();
5095    for edge in &rows.edges {
5096        *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
5097        if !node_ids.contains(edge.from_external_id.as_str()) {
5098            diagnostics.push(format!(
5099                "Convex snapshot edge {} references missing from node {}",
5100                edge.edge_key, edge.from_external_id
5101            ));
5102        }
5103        if !node_ids.contains(edge.to_external_id.as_str()) {
5104            diagnostics.push(format!(
5105                "Convex snapshot edge {} references missing to node {}",
5106                edge.edge_key, edge.to_external_id
5107            ));
5108        }
5109        let expected_key =
5110            ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
5111        if edge.edge_key != expected_key {
5112            diagnostics.push(format!(
5113                "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
5114                edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
5115            ));
5116        }
5117    }
5118    for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
5119        diagnostics.push(format!(
5120            "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
5121        ));
5122    }
5123    diagnostics
5124}
5125
5126pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
5127    let diagnostics = convex_projection_row_diagnostics(rows);
5128    if diagnostics.is_empty() {
5129        Ok(())
5130    } else {
5131        bail!("{}", diagnostics.join("; "))
5132    }
5133}
5134
5135pub(crate) struct ConvexHttpTransport {
5136    endpoint: String,
5137    auth_token_env: String,
5138    auth_token: Option<String>,
5139}
5140
5141impl ConvexHttpTransport {
5142    fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
5143        let endpoint = endpoint
5144            .map(str::to_string)
5145            .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
5146            .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
5147        let auth_token = env::var(auth_token_env)
5148            .ok()
5149            .filter(|value| !value.trim().is_empty());
5150        Ok(Self {
5151            endpoint,
5152            auth_token_env: auth_token_env.to_string(),
5153            auth_token,
5154        })
5155    }
5156
5157    fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
5158        ConvexTransportSummary {
5159            endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
5160            endpoint_configured: true,
5161            auth_token_env: self.auth_token_env.clone(),
5162            auth_configured: self.auth_token.is_some(),
5163            remote_snapshot,
5164            applied_chunks,
5165        }
5166    }
5167
5168    fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
5169        let mut builder = ureq::post(&self.endpoint);
5170        if let Some(token) = &self.auth_token {
5171            builder = builder.header("Authorization", &format!("Bearer {token}"));
5172        }
5173        builder
5174            .send_json(request)
5175            .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
5176            .body_mut()
5177            .read_json::<ConvexTransportResponse>()
5178            .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
5179    }
5180
5181    /// Fetch a full snapshot of the Convex graph backend.
5182    ///
5183    /// Uses the paginated `snapshot_meta` + `snapshot_nodes_page` +
5184    /// `snapshot_edges_page` triplet so the call works on tables larger than
5185    /// ~5k rows (the single-shot `snapshot` query hits Convex's 15s per-request
5186    /// syscall budget at that scale; see `#convexsnapshotscale`).
5187    ///
5188    /// Falls back to the legacy single-shot `snapshot` operation if the
5189    /// backend doesn't recognize `snapshot_meta` (older deployments that
5190    /// haven't redeployed the new schema).
5191    fn fetch_snapshot(
5192        &self,
5193        projection_version: &str,
5194        scope: Option<&str>,
5195        local_hash: Option<&str>,
5196        local_rows: Option<&ConvexProjectionRows>,
5197    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5198        match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
5199            Ok(rows) => Ok(rows),
5200            Err(err) => {
5201                // Only fall through to the legacy path if the failure looks
5202                // like "operation unknown" (older backend). Any other failure
5203                // (HTTP timeout, deserialization mismatch) should surface so
5204                // the operator sees the real cause.
5205                let msg = format!("{err:#}");
5206                let is_unknown_op = msg.contains("unknown operation")
5207                    || msg.contains("snapshot_meta")
5208                    || msg.contains("404");
5209                if !is_unknown_op {
5210                    return Err(err);
5211                }
5212                self.fetch_snapshot_legacy(projection_version)
5213                    .map(|rows| (rows, Vec::new()))
5214            }
5215        }
5216    }
5217
5218    fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
5219        let response = self.post(&ConvexTransportRequest {
5220            operation: "snapshot",
5221            chunk: 0,
5222            projection_version,
5223            projection_hash: None,
5224            projection_meta_id: None,
5225            node_rows: Vec::new(),
5226            edge_rows: Vec::new(),
5227            keys: Vec::new(),
5228            cursor: None,
5229            limit: None,
5230        })?;
5231        response
5232            .rows
5233            .context("Convex snapshot response did not include rows")
5234    }
5235
5236    fn fetch_snapshot_paginated(
5237        &self,
5238        projection_version: &str,
5239        scope: Option<&str>,
5240        local_hash: Option<&str>,
5241        local_rows: Option<&ConvexProjectionRows>,
5242    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5243        let projection_meta_id = graph_projection_meta_id(scope);
5244        let meta_response = self.post(&ConvexTransportRequest {
5245            operation: "snapshot_meta",
5246            chunk: 0,
5247            projection_version,
5248            projection_hash: None,
5249            projection_meta_id: Some(&projection_meta_id),
5250            node_rows: Vec::new(),
5251            edge_rows: Vec::new(),
5252            keys: Vec::new(),
5253            cursor: None,
5254            limit: None,
5255        })?;
5256        if matches!(meta_response.status.as_deref(), Some("error")) {
5257            anyhow::bail!(
5258                "Convex snapshot_meta returned error: {}",
5259                meta_response.message.unwrap_or_default()
5260            );
5261        }
5262        let meta = meta_response
5263            .meta
5264            .context("Convex snapshot_meta response did not include meta")?;
5265        if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
5266            (meta.projection_hash.as_deref(), local_hash, local_rows)
5267            && remote_hash == local_hash
5268        {
5269            return Ok((
5270                local_rows.clone(),
5271                vec![
5272                    "remote projection hash matched local graph; skipped full row-page snapshot diff"
5273                        .to_string(),
5274                ],
5275            ));
5276        }
5277
5278        let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
5279        let mut node_cursor: Option<String> = None;
5280        loop {
5281            let response = self.post(&ConvexTransportRequest {
5282                operation: "snapshot_nodes_page",
5283                chunk: 0,
5284                projection_version,
5285                projection_hash: None,
5286                projection_meta_id: None,
5287                node_rows: Vec::new(),
5288                edge_rows: Vec::new(),
5289                keys: Vec::new(),
5290                cursor: node_cursor.clone(),
5291                limit: None,
5292            })?;
5293            let page = response
5294                .page
5295                .context("Convex snapshot_nodes_page response did not include page")?;
5296            for raw in page.rows {
5297                let row: ConvexNodeRow =
5298                    serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
5299                nodes.push(row);
5300            }
5301            match page.next_cursor {
5302                Some(next) => node_cursor = Some(next),
5303                None => break,
5304            }
5305        }
5306
5307        let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
5308        let mut edge_cursor: Option<String> = None;
5309        loop {
5310            let response = self.post(&ConvexTransportRequest {
5311                operation: "snapshot_edges_page",
5312                chunk: 0,
5313                projection_version,
5314                projection_hash: None,
5315                projection_meta_id: None,
5316                node_rows: Vec::new(),
5317                edge_rows: Vec::new(),
5318                keys: Vec::new(),
5319                cursor: edge_cursor.clone(),
5320                limit: None,
5321            })?;
5322            let page = response
5323                .page
5324                .context("Convex snapshot_edges_page response did not include page")?;
5325            for raw in page.rows {
5326                let row: ConvexEdgeRow =
5327                    serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
5328                edges.push(row);
5329            }
5330            match page.next_cursor {
5331                Some(next) => edge_cursor = Some(next),
5332                None => break,
5333            }
5334        }
5335
5336        Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
5337    }
5338
5339    fn apply_chunk(
5340        &self,
5341        report: &ConvexSyncReport,
5342        chunk: &ConvexSyncChunk,
5343    ) -> Result<ConvexTransportReceipt> {
5344        let node_rows = if chunk.operation == "upsert_nodes" {
5345            report
5346                .node_upserts
5347                .iter()
5348                .filter(|row| chunk.keys.contains(&row.external_id))
5349                .cloned()
5350                .collect()
5351        } else {
5352            Vec::new()
5353        };
5354        let edge_rows = if chunk.operation == "upsert_edges" {
5355            report
5356                .edge_upserts
5357                .iter()
5358                .filter(|row| chunk.keys.contains(&row.edge_key))
5359                .cloned()
5360                .collect()
5361        } else {
5362            Vec::new()
5363        };
5364        let request = ConvexTransportRequest {
5365            operation: &chunk.operation,
5366            chunk: chunk.chunk,
5367            projection_version: &report.projection_version,
5368            projection_hash: report.projection_hash.as_deref(),
5369            projection_meta_id: None,
5370            node_rows,
5371            edge_rows,
5372            keys: chunk.keys.clone(),
5373            cursor: None,
5374            limit: None,
5375        };
5376        let mut last_error = None;
5377        for attempt in 1..=chunk.max_attempts {
5378            match self.post(&request) {
5379                Ok(response) => {
5380                    return Ok(ConvexTransportReceipt {
5381                        operation: chunk.operation.clone(),
5382                        chunk: chunk.chunk,
5383                        attempt,
5384                        status: response.status.unwrap_or_else(|| "ok".to_string()),
5385                        message: response.message,
5386                    });
5387                }
5388                Err(err) => {
5389                    last_error = Some(err);
5390                    if attempt < chunk.max_attempts {
5391                        std::thread::sleep(Duration::from_millis(100 * attempt as u64));
5392                    }
5393                }
5394            }
5395        }
5396        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
5397            .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
5398    }
5399}
5400
5401fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
5402    let meta_id = graph_projection_meta_id(scope);
5403    rows.nodes
5404        .iter()
5405        .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
5406        .and_then(|row| row.properties.get("content_hash").cloned())
5407}
5408
5409fn convex_projection_freshness(
5410    local: &ConvexProjectionRows,
5411    snapshot: Option<&ConvexProjectionRows>,
5412    scope: Option<&str>,
5413) -> ConvexProjectionFreshness {
5414    let local_hash = convex_projection_hash(local, scope);
5415    let Some(snapshot) = snapshot else {
5416        return ConvexProjectionFreshness {
5417            status: "unchecked".to_string(),
5418            fail_closed: false,
5419            local_hash,
5420            snapshot_hash: None,
5421            missing_nodes: Vec::new(),
5422            stale_nodes: Vec::new(),
5423            missing_edges: Vec::new(),
5424            stale_edges: Vec::new(),
5425            diagnostics: vec![
5426                "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
5427            ],
5428        };
5429    };
5430
5431    let snapshot_hash = convex_projection_hash(snapshot, scope);
5432    let snapshot_nodes = snapshot
5433        .nodes
5434        .iter()
5435        .map(|row| (row.external_id.as_str(), row))
5436        .collect::<BTreeMap<_, _>>();
5437    let snapshot_edges = snapshot
5438        .edges
5439        .iter()
5440        .map(|row| (row.edge_key.as_str(), row))
5441        .collect::<BTreeMap<_, _>>();
5442
5443    let mut missing_nodes = Vec::new();
5444    let mut stale_nodes = Vec::new();
5445    for row in &local.nodes {
5446        match snapshot_nodes.get(row.external_id.as_str()) {
5447            Some(snapshot_row) if *snapshot_row == row => {}
5448            Some(_) => stale_nodes.push(row.external_id.clone()),
5449            None => missing_nodes.push(row.external_id.clone()),
5450        }
5451    }
5452
5453    let mut missing_edges = Vec::new();
5454    let mut stale_edges = Vec::new();
5455    for row in &local.edges {
5456        match snapshot_edges.get(row.edge_key.as_str()) {
5457            Some(snapshot_row) if *snapshot_row == row => {}
5458            Some(_) => stale_edges.push(row.edge_key.clone()),
5459            None => missing_edges.push(row.edge_key.clone()),
5460        }
5461    }
5462
5463    let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
5464    let rows_current = missing_nodes.is_empty()
5465        && stale_nodes.is_empty()
5466        && missing_edges.is_empty()
5467        && stale_edges.is_empty();
5468    if hash_current && rows_current {
5469        return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
5470    }
5471
5472    let mut diagnostics = Vec::new();
5473    if local_hash != snapshot_hash {
5474        diagnostics.push(format!(
5475            "projection hash mismatch: local={} snapshot={}",
5476            local_hash.as_deref().unwrap_or("missing"),
5477            snapshot_hash.as_deref().unwrap_or("missing")
5478        ));
5479    }
5480    if !missing_nodes.is_empty() || !missing_edges.is_empty() {
5481        diagnostics.push(format!(
5482            "Convex snapshot is missing {} node(s) and {} edge(s)",
5483            missing_nodes.len(),
5484            missing_edges.len()
5485        ));
5486    }
5487    if !stale_nodes.is_empty() || !stale_edges.is_empty() {
5488        diagnostics.push(format!(
5489            "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
5490            stale_nodes.len(),
5491            stale_edges.len()
5492        ));
5493    }
5494
5495    ConvexProjectionFreshness {
5496        status: "stale".to_string(),
5497        fail_closed: true,
5498        local_hash,
5499        snapshot_hash,
5500        missing_nodes,
5501        stale_nodes,
5502        missing_edges,
5503        stale_edges,
5504        diagnostics,
5505    }
5506}
5507
5508pub(crate) fn verify_convex_projection_snapshot(
5509    root: &Path,
5510    scope: Option<&str>,
5511    snapshot_path: &Path,
5512) -> Result<()> {
5513    let graph_db = graph_substrate_db_path(root, scope);
5514    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5515    let local = convex_rows_from_graph_store(&store)?;
5516    let snapshot = load_convex_projection_rows(snapshot_path)?;
5517    validate_convex_projection_rows(&snapshot)?;
5518    let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
5519    if freshness.fail_closed {
5520        bail!(
5521            "Convex graph projection is not current for {}: {}",
5522            root.display(),
5523            freshness.diagnostics.join("; ")
5524        );
5525    }
5526    Ok(())
5527}
5528
5529fn convex_rows_diff(
5530    local: &ConvexProjectionRows,
5531    snapshot: Option<&ConvexProjectionRows>,
5532) -> (
5533    Vec<ConvexNodeRow>,
5534    Vec<ConvexEdgeRow>,
5535    Vec<String>,
5536    Vec<String>,
5537) {
5538    let Some(snapshot) = snapshot else {
5539        return (
5540            local.nodes.clone(),
5541            local.edges.clone(),
5542            Vec::new(),
5543            Vec::new(),
5544        );
5545    };
5546    let local_nodes = local
5547        .nodes
5548        .iter()
5549        .map(|row| (row.external_id.as_str(), row))
5550        .collect::<BTreeMap<_, _>>();
5551    let local_edges = local
5552        .edges
5553        .iter()
5554        .map(|row| (row.edge_key.as_str(), row))
5555        .collect::<BTreeMap<_, _>>();
5556    let snapshot_nodes = snapshot
5557        .nodes
5558        .iter()
5559        .map(|row| (row.external_id.as_str(), row))
5560        .collect::<BTreeMap<_, _>>();
5561    let snapshot_edges = snapshot
5562        .edges
5563        .iter()
5564        .map(|row| (row.edge_key.as_str(), row))
5565        .collect::<BTreeMap<_, _>>();
5566
5567    let node_upserts = local
5568        .nodes
5569        .iter()
5570        .filter(|row| {
5571            snapshot_nodes
5572                .get(row.external_id.as_str())
5573                .is_none_or(|snapshot_row| *snapshot_row != *row)
5574        })
5575        .cloned()
5576        .collect::<Vec<_>>();
5577    let edge_upserts = local
5578        .edges
5579        .iter()
5580        .filter(|row| {
5581            snapshot_edges
5582                .get(row.edge_key.as_str())
5583                .is_none_or(|snapshot_row| *snapshot_row != *row)
5584        })
5585        .cloned()
5586        .collect::<Vec<_>>();
5587    let node_tombstones = snapshot
5588        .nodes
5589        .iter()
5590        .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
5591        .map(|row| row.external_id.clone())
5592        .collect::<Vec<_>>();
5593    let edge_tombstones = snapshot
5594        .edges
5595        .iter()
5596        .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
5597        .map(|row| row.edge_key.clone())
5598        .collect::<Vec<_>>();
5599
5600    (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
5601}
5602
5603fn push_sync_chunks(
5604    chunks: &mut Vec<ConvexSyncChunk>,
5605    operation: &str,
5606    keys: Vec<String>,
5607    size: usize,
5608) {
5609    if keys.is_empty() {
5610        return;
5611    }
5612    for (idx, chunk) in keys.chunks(size).enumerate() {
5613        chunks.push(ConvexSyncChunk {
5614            operation: operation.to_string(),
5615            chunk: idx + 1,
5616            count: chunk.len(),
5617            keys: chunk.to_vec(),
5618            max_attempts: 3,
5619            retry_policy:
5620                "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
5621                    .to_string(),
5622        });
5623    }
5624}
5625
5626pub(crate) fn build_convex_sync_report_with_snapshot(
5627    path: &Path,
5628    scope: Option<&str>,
5629    snapshot: Option<ConvexProjectionRows>,
5630    chunk_size: usize,
5631    dry_run: bool,
5632) -> Result<ConvexSyncReport> {
5633    if chunk_size == 0 {
5634        bail!("--chunk-size must be greater than zero");
5635    }
5636    let root = lint::resolve_project_root_or_canonical_path(path)?;
5637    let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
5638    let graph_db = graph_substrate_db_path(&root, scope);
5639    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5640    let local = convex_rows_from_graph_store(&store)?;
5641    let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
5642    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
5643        convex_rows_diff(&local, snapshot.as_ref());
5644
5645    let mut chunks = Vec::new();
5646    push_sync_chunks(
5647        &mut chunks,
5648        "delete_edges",
5649        edge_tombstones.clone(),
5650        chunk_size,
5651    );
5652    push_sync_chunks(
5653        &mut chunks,
5654        "upsert_nodes",
5655        node_upserts
5656            .iter()
5657            .map(|row| row.external_id.clone())
5658            .collect(),
5659        chunk_size,
5660    );
5661    push_sync_chunks(
5662        &mut chunks,
5663        "upsert_edges",
5664        edge_upserts
5665            .iter()
5666            .map(|row| row.edge_key.clone())
5667            .collect(),
5668        chunk_size,
5669    );
5670    push_sync_chunks(
5671        &mut chunks,
5672        "delete_nodes",
5673        node_tombstones.clone(),
5674        chunk_size,
5675    );
5676
5677    let mut diagnostics = vec![
5678        "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5679            .to_string(),
5680    ];
5681    if dry_run {
5682        diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5683    }
5684    if freshness.fail_closed {
5685        diagnostics.push(
5686            "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5687                .to_string(),
5688        );
5689    }
5690
5691    Ok(ConvexSyncReport {
5692        root: root.to_string_lossy().to_string(),
5693        scope: scope.map(str::to_string),
5694        graph_db: graph_db.to_string_lossy().to_string(),
5695        dry_run,
5696        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5697        projection_hash: convex_projection_hash(&local, scope),
5698        required_indexes: convex_required_indexes(),
5699        node_upserts,
5700        edge_upserts,
5701        node_tombstones,
5702        edge_tombstones,
5703        chunks,
5704        freshness,
5705        transport: None,
5706        receipts: Vec::new(),
5707        diagnostics,
5708        warnings: graph.warnings,
5709    })
5710}
5711
5712#[cfg(test)]
5713fn build_convex_sync_report(
5714    path: &Path,
5715    scope: Option<&str>,
5716    snapshot_path: Option<&Path>,
5717    chunk_size: usize,
5718) -> Result<ConvexSyncReport> {
5719    let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5720    build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5721}
5722
5723pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5724    if compact {
5725        println!(
5726            "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5727            report.node_upserts.len(),
5728            report.node_tombstones.len(),
5729            report.edge_upserts.len(),
5730            report.edge_tombstones.len(),
5731            report.chunks.len(),
5732            report.freshness.status
5733        );
5734        return;
5735    }
5736
5737    println!(
5738        "Convex graph sync {}",
5739        if report.dry_run { "dry-run" } else { "apply" }
5740    );
5741    println!("root: {}", report.root);
5742    println!("graph_db: {}", report.graph_db);
5743    println!(
5744        "upserts: {} node(s), {} edge(s)",
5745        report.node_upserts.len(),
5746        report.edge_upserts.len()
5747    );
5748    println!(
5749        "tombstones: {} node(s), {} edge(s)",
5750        report.node_tombstones.len(),
5751        report.edge_tombstones.len()
5752    );
5753    println!("chunks: {}", report.chunks.len());
5754    println!("freshness: {}", report.freshness.status);
5755    if let Some(transport) = &report.transport {
5756        println!(
5757            "transport: endpoint_env={} auth_env={} applied_chunks={}",
5758            transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5759        );
5760    }
5761    for receipt in &report.receipts {
5762        println!(
5763            "receipt: {} chunk {} attempt {} {}",
5764            receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5765        );
5766    }
5767    for diagnostic in report
5768        .diagnostics
5769        .iter()
5770        .chain(report.freshness.diagnostics.iter())
5771    {
5772        println!("- {}", diagnostic);
5773    }
5774}
5775
5776pub(crate) struct ConvexSyncOptions<'a> {
5777    path: &'a Path,
5778    scope: Option<&'a str>,
5779    snapshot: Option<&'a Path>,
5780    chunk_size: usize,
5781    remote_snapshot: bool,
5782    apply: bool,
5783    endpoint: Option<&'a str>,
5784    auth_token_env: &'a str,
5785}
5786
5787#[derive(Serialize)]
5788struct GraphDbSchemaField {
5789    name: &'static str,
5790    value_type: &'static str,
5791    description: &'static str,
5792}
5793
5794#[derive(Serialize)]
5795struct GraphDbSchemaOperation {
5796    command: &'static str,
5797    description: &'static str,
5798}
5799
5800#[derive(Serialize)]
5801struct GraphDbSchemaContract {
5802    name: &'static str,
5803    version: &'static str,
5804    description: &'static str,
5805}
5806
5807#[derive(Serialize)]
5808struct GraphDbSchema {
5809    contract_versions: Vec<GraphDbSchemaContract>,
5810    node_fields: Vec<GraphDbSchemaField>,
5811    edge_fields: Vec<GraphDbSchemaField>,
5812    operations: Vec<GraphDbSchemaOperation>,
5813}
5814
5815#[derive(Clone, Serialize, Deserialize)]
5816struct GraphDbFreshnessReport {
5817    status: String,
5818    fail_closed: bool,
5819    projection_version: Option<String>,
5820    content_hash: Option<String>,
5821    source_watermark: Option<String>,
5822    diagnostics: Vec<String>,
5823}
5824
5825#[derive(Clone, Debug, Serialize)]
5826pub(crate) struct GraphEffectivenessReadiness {
5827    pub(crate) status: String,
5828    pub(crate) fail_closed: bool,
5829    pub(crate) reason: String,
5830    pub(crate) diagnostics: Vec<String>,
5831    pub(crate) next_commands: Vec<String>,
5832}
5833
5834#[derive(Clone, Debug, Serialize, PartialEq)]
5835struct GraphDbPropertyFilter {
5836    key: String,
5837    value: String,
5838}
5839
5840#[derive(Clone, Debug, Default)]
5841struct GraphDbQueryOptions {
5842    cursor: Option<String>,
5843    limit: Option<usize>,
5844    property_filters: Vec<GraphDbPropertyFilter>,
5845}
5846
5847#[derive(Clone, Debug, Serialize, PartialEq)]
5848struct GraphDbPageReport {
5849    #[serde(skip_serializing_if = "Option::is_none")]
5850    cursor: Option<String>,
5851    #[serde(skip_serializing_if = "Option::is_none")]
5852    limit: Option<usize>,
5853    #[serde(skip_serializing_if = "Option::is_none")]
5854    next_cursor: Option<String>,
5855    returned_nodes: usize,
5856    returned_edges: usize,
5857    truncated: bool,
5858    property_filters: Vec<GraphDbPropertyFilter>,
5859    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5860    diagnostics: Vec<String>,
5861}
5862
5863type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5864
5865#[derive(Clone, Debug, Serialize)]
5866struct CommunityTruncationSummary {
5867    total_communities: usize,
5868    fully_kept: usize,
5869    partially_pruned: usize,
5870    fully_pruned: usize,
5871    pruned_community_kinds: Vec<String>,
5872    pruned_community_top_labels: Vec<String>,
5873}
5874
5875#[derive(Clone, Debug, Serialize)]
5876struct GraphDbRankedNeighborhoodComparison {
5877    traversal_nodes: usize,
5878    traversal_edges: usize,
5879    pruned_count: usize,
5880    total_discovered: usize,
5881    latency_micros: u128,
5882    overlap_with_unranked_pct: f64,
5883    useful_hit_density_ranked: f64,
5884    useful_hit_density_unranked: f64,
5885    duplicate_name_count_ranked: usize,
5886    duplicate_name_count_unranked: usize,
5887    handle_coverage_ranked_pct: f64,
5888    handle_coverage_unranked_pct: f64,
5889    #[serde(skip_serializing_if = "Option::is_none")]
5890    community_truncation_summary: Option<CommunityTruncationSummary>,
5891    diagnostics: Vec<String>,
5892}
5893
5894#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5895struct GraphDbDroppedByBudget {
5896    item: String,
5897    kind: String,
5898    dropped: usize,
5899    reason: String,
5900}
5901
5902#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5903struct GraphDbOutputBudgetReport {
5904    max_tokens: usize,
5905    estimated_tokens: usize,
5906    selected_nodes: usize,
5907    selected_edges: usize,
5908    candidate_nodes: usize,
5909    candidate_edges: usize,
5910    dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5911    diagnostics: Vec<String>,
5912}
5913
5914#[derive(Clone, Debug, Serialize, PartialEq)]
5915struct GraphDbKnowledgeRetrieval {
5916    mode: String,
5917    query: String,
5918    seed_kind: String,
5919    seed_limit: usize,
5920    seed_count: usize,
5921    depth: usize,
5922    limit: usize,
5923    node_count: usize,
5924    edge_count: usize,
5925    truncated: bool,
5926    traversal: String,
5927    freshness_boundary: String,
5928    privacy_boundary: String,
5929    diagnostics: Vec<String>,
5930}
5931
5932struct GraphDbSemanticSeededSubgraph {
5933    nodes: Vec<SubstrateGraphNode>,
5934    edges: Vec<SubstrateGraphEdge>,
5935    truncated: bool,
5936    diagnostics: Vec<String>,
5937}
5938
5939type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5940
5941#[derive(Serialize)]
5942struct GraphDbReport {
5943    root: String,
5944    #[serde(skip_serializing_if = "Option::is_none")]
5945    scope: Option<String>,
5946    backend: String,
5947    query: String,
5948    freshness: GraphDbFreshnessReport,
5949    #[serde(skip_serializing_if = "Option::is_none")]
5950    readiness: Option<GraphEffectivenessReadiness>,
5951    #[serde(skip_serializing_if = "Option::is_none")]
5952    schema: Option<GraphDbSchema>,
5953    #[serde(skip_serializing_if = "Option::is_none")]
5954    node: Option<SubstrateTerseGraphNode>,
5955    #[serde(skip_serializing_if = "Option::is_none")]
5956    edge: Option<SubstrateTerseGraphEdge>,
5957    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5958    nodes: Vec<SubstrateTerseGraphNode>,
5959    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5960    edges: Vec<SubstrateTerseGraphEdge>,
5961    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5962    ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5963    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5964    semantic_related: Vec<SemanticRelatedItem>,
5965    #[serde(skip_serializing_if = "Option::is_none")]
5966    neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5967    #[serde(skip_serializing_if = "Option::is_none")]
5968    ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5969    #[serde(skip_serializing_if = "Option::is_none")]
5970    knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5971    #[serde(skip_serializing_if = "Option::is_none")]
5972    output_budget: Option<GraphDbOutputBudgetReport>,
5973    #[serde(skip_serializing_if = "Option::is_none")]
5974    path: Option<substrate::GraphPath>,
5975    #[serde(skip_serializing_if = "Option::is_none")]
5976    page: Option<GraphDbPageReport>,
5977    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5978    warnings: Vec<String>,
5979}
5980
5981struct ExperimentalReadOnlyGraphStore {
5982    backend: GraphDbExperimentalBackend,
5983    nodes: BTreeMap<String, SubstrateGraphNode>,
5984    edges: BTreeMap<String, SubstrateGraphEdge>,
5985    node_ids_by_kind: BTreeMap<String, Vec<String>>,
5986    outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5987}
5988
5989impl ExperimentalReadOnlyGraphStore {
5990    fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5991        validate_convex_projection_rows(rows)?;
5992        let nodes = rows
5993            .nodes
5994            .iter()
5995            .map(|row| {
5996                let node = SubstrateGraphNode {
5997                    id: row.external_id.clone(),
5998                    kind: row.kind.clone(),
5999                    label: row.label.clone(),
6000                    properties: row.properties.clone(),
6001                    provenance: row.provenance.clone(),
6002                    freshness: row.freshness.clone(),
6003                };
6004                (node.id.clone(), node)
6005            })
6006            .collect::<BTreeMap<_, _>>();
6007        let edges = rows
6008            .edges
6009            .iter()
6010            .map(|row| {
6011                let edge = SubstrateGraphEdge {
6012                    id: row.edge_key.clone(),
6013                    from_id: row.from_external_id.clone(),
6014                    to_id: row.to_external_id.clone(),
6015                    kind: row.kind.clone(),
6016                    properties: row.properties.clone(),
6017                    provenance: row.provenance.clone(),
6018                    freshness: row.freshness.clone(),
6019                };
6020                (graph_db_edge_key(&edge), edge)
6021            })
6022            .collect::<BTreeMap<_, _>>();
6023        let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
6024        for node in nodes.values() {
6025            node_ids_by_kind
6026                .entry(node.kind.clone())
6027                .or_default()
6028                .push(node.id.clone());
6029        }
6030        for ids in node_ids_by_kind.values_mut() {
6031            ids.sort();
6032        }
6033        let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
6034        for edge in edges.values() {
6035            outgoing_edge_keys_by_from
6036                .entry(edge.from_id.clone())
6037                .or_default()
6038                .push(graph_db_edge_key(edge));
6039        }
6040        for edge_keys in outgoing_edge_keys_by_from.values_mut() {
6041            edge_keys.sort_by(|left_key, right_key| {
6042                let left = &edges[left_key];
6043                let right = &edges[right_key];
6044                left.to_id
6045                    .cmp(&right.to_id)
6046                    .then(left.kind.cmp(&right.kind))
6047                    .then(left_key.cmp(right_key))
6048            });
6049        }
6050        Ok(Self {
6051            backend,
6052            nodes,
6053            edges,
6054            node_ids_by_kind,
6055            outgoing_edge_keys_by_from,
6056        })
6057    }
6058}
6059
6060impl GraphStore for ExperimentalReadOnlyGraphStore {
6061    fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
6062        bail!("{} backend-eval adapter is read-only", self.backend.name())
6063    }
6064
6065    fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
6066        bail!("{} backend-eval adapter is read-only", self.backend.name())
6067    }
6068
6069    fn delete_node(&self, _id: &str) -> Result<usize> {
6070        bail!("{} backend-eval adapter is read-only", self.backend.name())
6071    }
6072
6073    fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
6074        bail!("{} backend-eval adapter is read-only", self.backend.name())
6075    }
6076
6077    fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
6078        Ok(self.nodes.get(id).cloned())
6079    }
6080
6081    fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
6082        Ok(self.nodes.values().cloned().collect())
6083    }
6084
6085    fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
6086        let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
6087        edges.sort_by(|left, right| {
6088            left.from_id
6089                .cmp(&right.from_id)
6090                .then(left.kind.cmp(&right.kind))
6091                .then(left.to_id.cmp(&right.to_id))
6092        });
6093        Ok(edges)
6094    }
6095
6096    fn graph_counts(&self) -> Result<(usize, usize)> {
6097        Ok((self.nodes.len(), self.edges.len()))
6098    }
6099
6100    fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
6101        let mut edges = self
6102            .edges
6103            .values()
6104            .filter(|edge| edge.from_id != edge.to_id)
6105            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6106            .cloned()
6107            .collect::<Vec<_>>();
6108        edges.sort_by(|left, right| {
6109            left.from_id
6110                .cmp(&right.from_id)
6111                .then(left.kind.cmp(&right.kind))
6112                .then(left.to_id.cmp(&right.to_id))
6113        });
6114        Ok(edges.into_iter().next())
6115    }
6116
6117    fn sample_edge_with_property(
6118        &self,
6119    ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
6120        Ok(self
6121            .edges
6122            .values()
6123            .filter(|edge| edge.from_id != edge.to_id)
6124            .filter_map(|edge| {
6125                edge.properties.iter().next().map(|(key, value)| {
6126                    (
6127                        edge,
6128                        GraphPropertyFilter {
6129                            key: key.clone(),
6130                            value: value.clone(),
6131                        },
6132                    )
6133                })
6134            })
6135            .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
6136                left_filter
6137                    .key
6138                    .cmp(&right_filter.key)
6139                    .then(left_filter.value.cmp(&right_filter.value))
6140                    .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
6141            })
6142            .map(|(edge, filter)| (edge.clone(), filter)))
6143    }
6144
6145    fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
6146        Ok(self
6147            .node_ids_by_kind
6148            .get(kind)
6149            .into_iter()
6150            .flatten()
6151            .filter_map(|id| self.nodes.get(id).cloned())
6152            .collect())
6153    }
6154
6155    fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
6156        Ok(self
6157            .outgoing_edge_keys_by_from
6158            .get(from_id)
6159            .into_iter()
6160            .flatten()
6161            .filter_map(|key| self.edges.get(key))
6162            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6163            .cloned()
6164            .collect())
6165    }
6166
6167    fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
6168        Ok(self
6169            .edges
6170            .values()
6171            .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
6172            .cloned()
6173            .collect())
6174    }
6175
6176    fn shortest_path(
6177        &self,
6178        from_id: &str,
6179        to_id: &str,
6180        kind: Option<&str>,
6181    ) -> Result<Option<substrate::GraphPath>> {
6182        if from_id == to_id {
6183            return Ok(Some(substrate::GraphPath {
6184                nodes: vec![from_id.to_string()],
6185                hops: 0,
6186            }));
6187        }
6188
6189        let mut queue = VecDeque::new();
6190        let mut parent = BTreeMap::<String, String>::new();
6191        parent.insert(from_id.to_string(), String::new());
6192        queue.push_back(from_id.to_string());
6193
6194        while let Some(current) = queue.pop_front() {
6195            for edge in self.outgoing_edges(&current, kind)? {
6196                if parent.contains_key(&edge.to_id) {
6197                    continue;
6198                }
6199                parent.insert(edge.to_id.clone(), current.clone());
6200                if edge.to_id == to_id {
6201                    let mut nodes = vec![to_id.to_string()];
6202                    let mut cursor = to_id;
6203                    while let Some(previous) = parent.get(cursor) {
6204                        if previous.is_empty() {
6205                            break;
6206                        }
6207                        nodes.push(previous.clone());
6208                        cursor = previous;
6209                    }
6210                    nodes.reverse();
6211                    return Ok(Some(substrate::GraphPath {
6212                        hops: nodes.len().saturating_sub(1),
6213                        nodes,
6214                    }));
6215                }
6216                queue.push_back(edge.to_id);
6217            }
6218        }
6219
6220        Ok(None)
6221    }
6222
6223    fn reachable_nodes_by_kinds(
6224        &self,
6225        from_id: &str,
6226        kinds: &[&str],
6227        depth: usize,
6228        limit: usize,
6229    ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
6230        let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
6231        let mut rows = requested
6232            .iter()
6233            .map(|kind| {
6234                (
6235                    (*kind).to_string(),
6236                    BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
6237                )
6238            })
6239            .collect::<BTreeMap<_, _>>();
6240        if requested.is_empty() {
6241            return Ok(BTreeMap::new());
6242        }
6243
6244        let mut seen = BTreeSet::from([from_id.to_string()]);
6245        let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
6246        while let Some((current, path)) = queue.pop_front() {
6247            let current_depth = path.len().saturating_sub(1);
6248            if current_depth >= depth {
6249                continue;
6250            }
6251            for edge in self.outgoing_edges(&current, None)? {
6252                if !seen.insert(edge.to_id.clone()) {
6253                    continue;
6254                }
6255                let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
6256                    continue;
6257                };
6258                let mut next_path = path.clone();
6259                next_path.push(edge.to_id.clone());
6260                let graph_path = substrate::GraphPath {
6261                    hops: next_path.len().saturating_sub(1),
6262                    nodes: next_path.clone(),
6263                };
6264                if requested.contains(node.kind.as_str()) {
6265                    rows.entry(node.kind.clone())
6266                        .or_default()
6267                        .entry(node.id.clone())
6268                        .or_insert((node.clone(), graph_path));
6269                }
6270                queue.push_back((edge.to_id, next_path));
6271            }
6272        }
6273
6274        Ok(rows
6275            .into_iter()
6276            .map(|(kind, values)| {
6277                let mut values = values.into_values().collect::<Vec<_>>();
6278                values.sort_by(|(left_node, left_path), (right_node, right_path)| {
6279                    left_path
6280                        .hops
6281                        .cmp(&right_path.hops)
6282                        .then(left_node.label.cmp(&right_node.label))
6283                        .then(left_node.id.cmp(&right_node.id))
6284                });
6285                if limit > 0 && values.len() > limit {
6286                    values.truncate(limit);
6287                }
6288                (kind, values)
6289            })
6290            .collect())
6291    }
6292}
6293
6294pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
6295pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
6296pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
6297const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
6298pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
6299const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
6300const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
6301const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
6302const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
6303
6304#[derive(Clone, Serialize, Deserialize)]
6305pub(crate) struct GraphDbBackendEvalPhaseTiming {
6306    name: String,
6307    duration_micros: u128,
6308    detail: String,
6309}
6310
6311#[derive(Serialize, Deserialize)]
6312struct GraphDbBackendEvalFullProjectionCache {
6313    version: String,
6314    key: String,
6315    source_watermark: String,
6316    projection: GraphProjection,
6317    warnings: Vec<String>,
6318}
6319
6320#[derive(Clone, Default)]
6321struct GraphDbBackendEvalFullProjectionCacheStats {
6322    hit: bool,
6323    disk_bytes: u64,
6324    json_bytes: u64,
6325    pruned_files: usize,
6326    pruned_bytes: u64,
6327}
6328
6329#[derive(Serialize)]
6330struct GraphDbBackendEvalRawSourceWatermarkRow {
6331    path: String,
6332    bytes: u64,
6333    content_hash: String,
6334}
6335
6336#[derive(Clone)]
6337struct GraphDbBackendEvalFullProjectionSourceWatermark {
6338    value: String,
6339    detail: String,
6340}
6341
6342#[derive(Serialize)]
6343pub(crate) struct GraphDbBackendEvalConfig {
6344    high_degree_nodes: usize,
6345    high_degree_fanout: usize,
6346    deep_chain_nodes: usize,
6347    deep_chain_fanout: usize,
6348    depth: usize,
6349    limit: usize,
6350    impact_limit: usize,
6351    path_max_hops: usize,
6352    path_direct_hop_budget: usize,
6353    path_deep_chain_hop_budget: usize,
6354    path_extended_hop_budgets: Vec<usize>,
6355    path_hop_policy: String,
6356    path_probe_strategy: String,
6357    path_query_plan_checks: Vec<String>,
6358    full_projection_enabled: bool,
6359    full_projection_profile: String,
6360    normalization_row_unit: usize,
6361}
6362
6363#[derive(Clone)]
6364struct GraphDbBackendEvalSignature {
6365    operation: String,
6366    value: serde_json::Value,
6367}
6368
6369#[derive(Serialize)]
6370struct GraphDbBackendEvalOperation {
6371    name: String,
6372    supported: bool,
6373    status: String,
6374    duration_micros: u128,
6375    #[serde(skip_serializing_if = "Option::is_none")]
6376    rows: Option<usize>,
6377    #[serde(skip_serializing_if = "Option::is_none")]
6378    error: Option<String>,
6379}
6380
6381#[derive(Serialize)]
6382struct GraphDbBackendEvalParity {
6383    matches_sqlite: bool,
6384    diagnostics: Vec<String>,
6385}
6386
6387#[derive(Serialize)]
6388struct GraphDbBackendEvalBackendReport {
6389    backend: String,
6390    adapter: String,
6391    read_only: bool,
6392    projection_load: String,
6393    operations: Vec<GraphDbBackendEvalOperation>,
6394    total_micros: u128,
6395    parity: GraphDbBackendEvalParity,
6396    lock_behavior: String,
6397    install_portability: String,
6398}
6399
6400#[derive(Serialize)]
6401struct GraphDbBackendEvalDataset {
6402    name: String,
6403    target_count: usize,
6404    nodes: usize,
6405    edges: usize,
6406    backends: Vec<GraphDbBackendEvalBackendReport>,
6407}
6408
6409#[derive(Serialize)]
6410struct GraphDbBackendPromotionDecision {
6411    backend: String,
6412    decision: String,
6413    reasons: Vec<String>,
6414    gate: GraphDbBackendPromotionGate,
6415}
6416
6417#[derive(Serialize)]
6418struct GraphDbBackendEvalPerformanceGate {
6419    baseline_fixture: String,
6420    ci_profile: String,
6421    opt_in_real_profile: String,
6422    full_projection_cache_hit_gate: String,
6423    allowed_regression_percent: f64,
6424    minimum_sample_runs: usize,
6425    normalized_metric_unit: String,
6426    required_metrics: Vec<String>,
6427    digest_command: String,
6428    repeated_sample_command: String,
6429    hop_cap_promotion: GraphDbHopCapPromotionGate,
6430    backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
6431}
6432
6433#[derive(Serialize)]
6434struct GraphDbHopCapPromotionGate {
6435    status: String,
6436    current_default_hops: usize,
6437    candidate_hop_tiers: Vec<usize>,
6438    required_backend: String,
6439    required_workloads: Vec<String>,
6440    required_metrics: Vec<String>,
6441    allowed_regression_percent: f64,
6442    minimum_sample_runs: usize,
6443    decision_rule: String,
6444}
6445
6446#[derive(Serialize)]
6447struct GraphDbBackendAdapterSpikeGate {
6448    status: String,
6449    candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
6450    required_workloads: Vec<String>,
6451    required_checks: Vec<String>,
6452    decision_rule: String,
6453    evidence_plan: String,
6454}
6455
6456#[derive(Serialize)]
6457struct GraphDbBackendAdapterSpikeCandidate {
6458    backend: String,
6459    adapter_label: String,
6460    projection_load: String,
6461    lock_behavior: String,
6462    install_portability: String,
6463}
6464
6465#[derive(Serialize)]
6466pub(crate) struct GraphDbBackendEvalReport {
6467    root: String,
6468    #[serde(skip_serializing_if = "Option::is_none")]
6469    scope: Option<String>,
6470    label: String,
6471    baseline_backend: String,
6472    candidates: Vec<String>,
6473    targets: Vec<String>,
6474    config: GraphDbBackendEvalConfig,
6475    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6476    datasets: Vec<GraphDbBackendEvalDataset>,
6477    promotion: Vec<GraphDbBackendPromotionDecision>,
6478    performance_gate: GraphDbBackendEvalPerformanceGate,
6479    metrics: BTreeMap<String, f64>,
6480    metric_digest_command: String,
6481    warnings: Vec<String>,
6482}
6483
6484#[derive(Clone, Debug, Serialize)]
6485struct GraphDbDoctorCheck {
6486    name: String,
6487    status: String,
6488    fail_closed: bool,
6489    diagnostics: Vec<String>,
6490    repair_commands: Vec<String>,
6491}
6492
6493#[derive(Serialize)]
6494pub(crate) struct GraphDbDoctorReport {
6495    root: String,
6496    #[serde(skip_serializing_if = "Option::is_none")]
6497    scope: Option<String>,
6498    backend: String,
6499    graph_db: String,
6500    #[serde(skip_serializing_if = "Option::is_none")]
6501    convex_snapshot: Option<String>,
6502    status: String,
6503    fail_closed: bool,
6504    checks: Vec<GraphDbDoctorCheck>,
6505    repair_commands: Vec<String>,
6506    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6507    required_indexes: Vec<ConvexRequiredIndex>,
6508}
6509
6510#[derive(Serialize)]
6511struct GraphDbDriftSummary {
6512    node_upserts: usize,
6513    edge_upserts: usize,
6514    node_tombstones: usize,
6515    edge_tombstones: usize,
6516    stale_nodes: usize,
6517    stale_edges: usize,
6518    stale_projection_metadata: usize,
6519    duplicate_failures: usize,
6520    orphan_failures: usize,
6521    missing_required_indexes: usize,
6522}
6523
6524#[derive(Serialize)]
6525struct GraphDbDriftReport {
6526    root: String,
6527    #[serde(skip_serializing_if = "Option::is_none")]
6528    scope: Option<String>,
6529    graph_db: String,
6530    convex_snapshot: String,
6531    status: String,
6532    graph_reads_allowed: bool,
6533    projection_version: String,
6534    local_hash: Option<String>,
6535    snapshot_hash: Option<String>,
6536    summary: GraphDbDriftSummary,
6537    node_upserts: Vec<String>,
6538    edge_upserts: Vec<String>,
6539    node_tombstones: Vec<String>,
6540    edge_tombstones: Vec<String>,
6541    stale_nodes: Vec<String>,
6542    stale_edges: Vec<String>,
6543    diagnostics: Vec<String>,
6544    next_commands: Vec<String>,
6545    required_indexes: Vec<ConvexRequiredIndex>,
6546    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6547    warnings: Vec<String>,
6548}
6549
6550#[derive(Clone, Serialize)]
6551struct GraphDbTombstoneCounts {
6552    nodes: usize,
6553    edges: usize,
6554    total: usize,
6555}
6556
6557#[derive(Clone, Serialize)]
6558struct GraphDbOperatorCounts {
6559    nodes: usize,
6560    edges: usize,
6561    tombstones: GraphDbTombstoneCounts,
6562    #[serde(skip_serializing_if = "Option::is_none")]
6563    file_size_bytes: Option<u64>,
6564    #[serde(skip_serializing_if = "Option::is_none")]
6565    freelist_bytes: Option<u64>,
6566}
6567
6568#[derive(Clone, Serialize)]
6569struct GraphDbCompactionPolicy {
6570    status: String,
6571    tombstone_scan_rows: usize,
6572    live_rows: usize,
6573    file_size_bytes: Option<u64>,
6574    freelist_bytes: Option<u64>,
6575    safe_to_prune_tombstones: bool,
6576    requires_convex_reconciliation: bool,
6577    recommendations: Vec<String>,
6578    proof: Vec<String>,
6579}
6580
6581#[derive(Serialize)]
6582pub(crate) struct GraphDbRefreshSummary {
6583    scope: String,
6584    projection_version: String,
6585    mode: String,
6586    #[serde(skip_serializing_if = "Option::is_none")]
6587    source_watermark: Option<String>,
6588    tombstoned_nodes: usize,
6589    tombstoned_edges: usize,
6590    upserted_nodes: usize,
6591    upserted_edges: usize,
6592    unchanged_nodes: usize,
6593    unchanged_edges: usize,
6594    upserted_properties: usize,
6595    unchanged_properties: usize,
6596    deleted_properties: usize,
6597    deleted_nodes: usize,
6598    deleted_edges: usize,
6599    pruned_tombstones: usize,
6600    #[serde(skip_serializing_if = "Option::is_none")]
6601    file_size_bytes_before: Option<u64>,
6602    #[serde(skip_serializing_if = "Option::is_none")]
6603    file_size_bytes_after: Option<u64>,
6604    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6605    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6606}
6607
6608#[derive(Serialize)]
6609struct GraphDbOperatorReport {
6610    root: String,
6611    #[serde(skip_serializing_if = "Option::is_none")]
6612    scope: Option<String>,
6613    graph_db: String,
6614    operation: String,
6615    status: String,
6616    materialized: bool,
6617    freshness: GraphDbFreshnessReport,
6618    readiness: GraphEffectivenessReadiness,
6619    counts: GraphDbOperatorCounts,
6620    #[serde(skip_serializing_if = "Option::is_none")]
6621    refresh: Option<GraphDbRefreshSummary>,
6622    compaction: GraphDbCompactionPolicy,
6623    #[serde(skip_serializing_if = "Option::is_none")]
6624    recovery: Option<index::ReadOnlyRecovery>,
6625    next_commands: Vec<String>,
6626    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6627    warnings: Vec<String>,
6628}
6629
6630#[derive(Serialize)]
6631pub(crate) struct GraphDbCompactionReport {
6632    root: String,
6633    #[serde(skip_serializing_if = "Option::is_none")]
6634    scope: Option<String>,
6635    graph_db: String,
6636    applied: bool,
6637    pruned_tombstones: usize,
6638    counts_before: GraphDbOperatorCounts,
6639    counts_after: GraphDbOperatorCounts,
6640    compaction_before: GraphDbCompactionPolicy,
6641    compaction_after: GraphDbCompactionPolicy,
6642    reclaimed_bytes: i64,
6643    next_commands: Vec<String>,
6644    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6645    warnings: Vec<String>,
6646}
6647
6648#[derive(Clone, Serialize, Deserialize)]
6649struct GraphDbEvidencePath {
6650    to: String,
6651    kind: String,
6652    label: String,
6653    #[serde(skip_serializing_if = "Option::is_none")]
6654    path: Option<substrate::GraphPath>,
6655    #[serde(skip_serializing_if = "Option::is_none")]
6656    expand: Option<String>,
6657}
6658
6659#[derive(Clone, Serialize, Deserialize)]
6660struct GraphDbFixtureCoverage {
6661    test: String,
6662    fixture: String,
6663    assertions: Vec<String>,
6664}
6665
6666#[derive(Clone, Serialize, Deserialize)]
6667struct GraphDbEvidenceReport {
6668    root: String,
6669    #[serde(skip_serializing_if = "Option::is_none")]
6670    scope: Option<String>,
6671    backend: String,
6672    contract_version: String,
6673    target: String,
6674    packet_id: String,
6675    #[serde(skip_serializing_if = "Option::is_none")]
6676    projection_hash: Option<String>,
6677    freshness: GraphDbFreshnessReport,
6678    target_node: SubstrateTerseGraphNode,
6679    worker_context: Vec<SubstrateTerseGraphNode>,
6680    source_handles: Vec<SubstrateTerseGraphNode>,
6681    worker_results: Vec<SubstrateTerseGraphNode>,
6682    semantic_related: Vec<SubstrateTerseGraphNode>,
6683    shortest_paths: Vec<GraphDbEvidencePath>,
6684    #[serde(skip_serializing_if = "Option::is_none")]
6685    output_budget: Option<GraphDbOutputBudgetReport>,
6686    #[serde(default)]
6687    truncated: bool,
6688    #[serde(skip_serializing_if = "Option::is_none")]
6689    next_cursor: Option<String>,
6690    next_commands: Vec<String>,
6691    replay_commands: Vec<String>,
6692    repair_commands: Vec<String>,
6693    fixture_coverage: GraphDbFixtureCoverage,
6694    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6695    warnings: Vec<String>,
6696}
6697
6698pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6699    root: &'a Path,
6700    scope: Option<&'a str>,
6701    backend: &'a str,
6702    target: &'a str,
6703    preferred_path: Option<&'a str>,
6704    depth: usize,
6705    limit: usize,
6706    cursor: Option<&'a str>,
6707    store: &'a S,
6708    freshness: GraphDbFreshnessReport,
6709    warnings: Vec<String>,
6710}
6711
6712impl GraphDbDoctorReport {
6713    fn new(
6714        root: &Path,
6715        scope: Option<&str>,
6716        backend: &str,
6717        graph_db: &Path,
6718        convex_snapshot: Option<&Path>,
6719    ) -> Self {
6720        Self {
6721            root: root.to_string_lossy().to_string(),
6722            scope: scope.map(str::to_string),
6723            backend: backend.to_string(),
6724            graph_db: graph_db.to_string_lossy().to_string(),
6725            convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6726            status: "ok".to_string(),
6727            fail_closed: false,
6728            checks: Vec::new(),
6729            repair_commands: Vec::new(),
6730            required_indexes: Vec::new(),
6731        }
6732    }
6733
6734    fn push_check(&mut self, check: GraphDbDoctorCheck) {
6735        self.checks.push(check);
6736    }
6737
6738    fn finalize(&mut self) {
6739        self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6740        self.status = if self.fail_closed {
6741            "fail_closed"
6742        } else {
6743            "ok"
6744        }
6745        .to_string();
6746        let mut commands = BTreeSet::new();
6747        for check in &self.checks {
6748            commands.extend(check.repair_commands.iter().cloned());
6749        }
6750        self.repair_commands = commands.into_iter().collect();
6751    }
6752
6753    fn summary(&self) -> String {
6754        self.checks
6755            .iter()
6756            .filter(|check| check.fail_closed)
6757            .flat_map(|check| check.diagnostics.iter())
6758            .take(3)
6759            .cloned()
6760            .collect::<Vec<_>>()
6761            .join("; ")
6762    }
6763}
6764
6765fn graph_db_doctor_check(
6766    name: impl Into<String>,
6767    diagnostics: Vec<String>,
6768    repair_commands: Vec<String>,
6769) -> GraphDbDoctorCheck {
6770    let fail_closed = !diagnostics.is_empty();
6771    GraphDbDoctorCheck {
6772        name: name.into(),
6773        status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6774        fail_closed,
6775        diagnostics,
6776        repair_commands: if fail_closed {
6777            repair_commands
6778        } else {
6779            Vec::new()
6780        },
6781    }
6782}
6783
6784pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6785    scope
6786        .map(|scope| format!(" --scope {}", shell_quote(scope)))
6787        .unwrap_or_default()
6788}
6789
6790fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6791    format!(
6792        "tsift graph-db --path {}{} refresh --json",
6793        shell_quote(root.to_string_lossy().as_ref()),
6794        graph_db_scope_arg(scope)
6795    )
6796}
6797
6798fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6799    graph_db_refresh_command(root, scope)
6800}
6801
6802fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6803    let backup = format!("{}.bak", graph_db.to_string_lossy());
6804    format!(
6805        "mv {} {} && {}",
6806        shell_quote(graph_db.to_string_lossy().as_ref()),
6807        shell_quote(&backup),
6808        graph_db_rebuild_command(root, scope)
6809    )
6810}
6811
6812fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6813    format!(
6814        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6815        shell_quote(root.to_string_lossy().as_ref()),
6816        graph_db_scope_arg(scope)
6817    )
6818}
6819
6820fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6821    substrate::open_graph_read_only_connection_resilient(graph_db)
6822}
6823
6824fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6825    conn.query_row(
6826        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6827        [table],
6828        |row| row.get::<_, bool>(0),
6829    )
6830    .map_err(Into::into)
6831}
6832
6833fn row_usize(row: &Row<'_>, idx: usize) -> rusqlite::Result<usize> {
6834    let value: i64 = row.get(idx)?;
6835    usize::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6836}
6837
6838fn row_u64(row: &Row<'_>, idx: usize) -> rusqlite::Result<u64> {
6839    let value: i64 = row.get(idx)?;
6840    u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6841}
6842
6843fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6844    let sql = match table {
6845        "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6846        "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6847        "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6848        other => bail!("unsupported graph count table {other}"),
6849    };
6850    conn.query_row(sql, [], |row| row_usize(row, 0))
6851        .map_err(Into::into)
6852}
6853
6854fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6855    if !sqlite_table_exists(conn, "graph_tombstones")? {
6856        return Ok(GraphDbTombstoneCounts {
6857            nodes: 0,
6858            edges: 0,
6859            total: 0,
6860        });
6861    }
6862    let mut stmt =
6863        conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6864    let mut rows = stmt.query([])?;
6865    let mut nodes = 0usize;
6866    let mut edges = 0usize;
6867    while let Some(row) = rows.next()? {
6868        let row_kind: String = row.get(0)?;
6869        let count = row_usize(row, 1)?;
6870        match row_kind.as_str() {
6871            "node" => nodes = count,
6872            "edge" => edges = count,
6873            _ => {}
6874        }
6875    }
6876    Ok(GraphDbTombstoneCounts {
6877        nodes,
6878        edges,
6879        total: nodes + edges,
6880    })
6881}
6882
6883fn sqlite_graph_counts_from_cache(
6884    conn: &Connection,
6885    scope: &str,
6886) -> Result<Option<GraphDbOperatorCounts>> {
6887    if !sqlite_table_exists(conn, "graph_operator_stats")? {
6888        return Ok(None);
6889    }
6890    let row = conn
6891        .query_row(
6892            r#"
6893        SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6894        FROM graph_operator_stats
6895        WHERE scope = ?1
6896        "#,
6897            [scope],
6898            |row| {
6899                Ok((
6900                    row_usize(row, 0)?,
6901                    row_usize(row, 1)?,
6902                    row_usize(row, 2)?,
6903                    row_usize(row, 3)?,
6904                    row.get::<_, Option<i64>>(4)?,
6905                    row.get::<_, Option<i64>>(5)?,
6906                ))
6907            },
6908        )
6909        .optional()?;
6910    Ok(row.map(
6911        |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6912            GraphDbOperatorCounts {
6913                nodes,
6914                edges,
6915                tombstones: GraphDbTombstoneCounts {
6916                    nodes: tombstone_nodes,
6917                    edges: tombstone_edges,
6918                    total: tombstone_nodes + tombstone_edges,
6919                },
6920                file_size_bytes: file_size_bytes
6921                    .and_then(|value| u64::try_from(value).ok())
6922                    .or_else(|| sqlite_database_size_bytes(conn).ok()),
6923                freelist_bytes: freelist_bytes
6924                    .and_then(|value| u64::try_from(value).ok())
6925                    .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6926            }
6927        },
6928    ))
6929}
6930
6931fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6932    if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6933        return Ok(counts);
6934    }
6935    let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6936        sqlite_known_table_count(conn, "graph_nodes")?
6937    } else {
6938        0
6939    };
6940    let edges = if sqlite_table_exists(conn, "graph_edges")? {
6941        sqlite_known_table_count(conn, "graph_edges")?
6942    } else {
6943        0
6944    };
6945    Ok(GraphDbOperatorCounts {
6946        nodes,
6947        edges,
6948        tombstones: sqlite_tombstone_counts(conn)?,
6949        file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6950        freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6951    })
6952}
6953
6954fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6955    if !sqlite_table_exists(conn, "graph_nodes")? {
6956        return Ok(0);
6957    }
6958    let count: i64 = conn.query_row(
6959        "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6960        [],
6961        |row| row.get(0),
6962    )?;
6963    Ok(count as usize)
6964}
6965
6966pub(crate) fn graph_db_compaction_policy(
6967    root: &Path,
6968    scope: Option<&str>,
6969    counts: &GraphDbOperatorCounts,
6970    prune_confirmed: bool,
6971) -> GraphDbCompactionPolicy {
6972    let live_rows = counts.nodes + counts.edges;
6973    let tombstone_scan_rows = counts.tombstones.total;
6974    let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6975    let freelist_heavy = counts
6976        .file_size_bytes
6977        .zip(counts.freelist_bytes)
6978        .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6979    let status = if tombstone_heavy || freelist_heavy {
6980        "recommended"
6981    } else {
6982        "not_needed"
6983    }
6984    .to_string();
6985    let mut recommendations = vec![
6986        convex_refresh_command(root, scope),
6987        graph_db_refresh_command(root, scope),
6988        format!(
6989            "tsift graph-db --path {}{} compact --apply --json",
6990            shell_quote(root.to_string_lossy().as_ref()),
6991            graph_db_scope_arg(scope)
6992        ),
6993    ];
6994    if prune_confirmed {
6995        recommendations.push(format!(
6996            "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6997            shell_quote(root.to_string_lossy().as_ref()),
6998            graph_db_scope_arg(scope)
6999        ));
7000    }
7001    let proof = vec![
7002        format!("{live_rows} live graph row(s)"),
7003        format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
7004        format!(
7005            "graph.db file_size={} byte(s), freelist={} byte(s)",
7006            counts.file_size_bytes.unwrap_or(0),
7007            counts.freelist_bytes.unwrap_or(0)
7008        ),
7009    ];
7010    GraphDbCompactionPolicy {
7011        status,
7012        tombstone_scan_rows,
7013        live_rows,
7014        file_size_bytes: counts.file_size_bytes,
7015        freelist_bytes: counts.freelist_bytes,
7016        safe_to_prune_tombstones: prune_confirmed,
7017        requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
7018        recommendations,
7019        proof,
7020    }
7021}
7022
7023fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
7024    let page_count = conn.query_row("PRAGMA page_count", [], |row| row_u64(row, 0))?;
7025    let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
7026    Ok(page_count.saturating_mul(page_size))
7027}
7028
7029fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
7030    let freelist_count = conn.query_row("PRAGMA freelist_count", [], |row| row_u64(row, 0))?;
7031    let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
7032    Ok(freelist_count.saturating_mul(page_size))
7033}
7034
7035fn sqlite_graph_tombstone_retention_diagnostics(
7036    conn: &Connection,
7037    scope: &str,
7038) -> Result<Vec<String>> {
7039    if !sqlite_table_exists(conn, "graph_tombstones")? {
7040        return Ok(Vec::new());
7041    }
7042    let cached = sqlite_graph_counts_from_cache(conn, scope)?;
7043    let counts = match cached.clone() {
7044        Some(counts) => counts,
7045        None => sqlite_graph_counts(conn, scope)?,
7046    };
7047    let live_rows = counts.nodes + counts.edges;
7048    let file_size = counts.file_size_bytes.unwrap_or(0);
7049    let freelist = counts.freelist_bytes.unwrap_or(0);
7050    let stale_live_tombstones = if cached.is_some() {
7051        0
7052    } else {
7053        let mut live_keys = BTreeSet::new();
7054        if sqlite_table_exists(conn, "graph_nodes")? {
7055            let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
7056            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7057                live_keys.insert(format!("node:{}", row?));
7058            }
7059        }
7060        if sqlite_table_exists(conn, "graph_edges")? {
7061            let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
7062            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7063                live_keys.insert(format!("edge:{}", row?));
7064            }
7065        }
7066        let mut stale_live_tombstones = 0usize;
7067        let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
7068        for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7069            if live_keys.contains(&row?) {
7070                stale_live_tombstones += 1;
7071            }
7072        }
7073        stale_live_tombstones
7074    };
7075
7076    let mut diagnostics = Vec::new();
7077    if stale_live_tombstones > 0 {
7078        diagnostics.push(format!(
7079            "{stale_live_tombstones} tombstone(s) reference rows that are live again; the next graph-db refresh prunes those stale tombstones before inserting new deletion markers"
7080        ));
7081    }
7082    if counts.tombstones.total > live_rows.max(1) {
7083        let source = if cached.is_some() {
7084            "cached refresh stats"
7085        } else {
7086            "live row scan"
7087        };
7088        diagnostics.push(format!(
7089            "tombstone retention exceeds live graph rows: {} tombstone(s) vs {} live row(s) from {}; graph.db file_size={} byte(s), freelist={} byte(s), status/doctor tombstone scans inspect {} extra row(s). Run convex-sync against the remote snapshot before rebuild/compaction if a remote consumer may still need deletion reconciliation.",
7090            counts.tombstones.total,
7091            live_rows,
7092            source,
7093            file_size,
7094            freelist,
7095            counts.tombstones.total
7096        ));
7097    }
7098    Ok(diagnostics)
7099}
7100
7101fn sqlite_graph_freshness_from_conn(
7102    conn: &Connection,
7103    scope: &str,
7104) -> Result<GraphDbFreshnessReport> {
7105    if !sqlite_table_exists(conn, "graph_projection_versions")? {
7106        return Ok(GraphDbFreshnessReport {
7107            status: "missing".to_string(),
7108            fail_closed: true,
7109            projection_version: None,
7110            content_hash: None,
7111            source_watermark: None,
7112            diagnostics: vec![
7113                "graph projection metadata table is missing; refresh graph.db before trusting reads"
7114                    .to_string(),
7115            ],
7116        });
7117    }
7118    let version = conn
7119        .query_row(
7120            r#"
7121            SELECT projection_version, content_hash, source_watermark
7122            FROM graph_projection_versions
7123            WHERE scope = ?1
7124            "#,
7125            [scope],
7126            |row| {
7127                Ok((
7128                    row.get::<_, String>(0)?,
7129                    row.get::<_, Option<String>>(1)?,
7130                    row.get::<_, Option<String>>(2)?,
7131                ))
7132            },
7133        )
7134        .optional()?;
7135    let Some((projection_version, content_hash, source_watermark)) = version else {
7136        return Ok(GraphDbFreshnessReport {
7137            status: "missing".to_string(),
7138            fail_closed: true,
7139            projection_version: None,
7140            content_hash: None,
7141            source_watermark: None,
7142            diagnostics: vec![
7143                "graph projection metadata is missing; refresh graph.db before trusting reads"
7144                    .to_string(),
7145            ],
7146        });
7147    };
7148
7149    let mut diagnostics = Vec::new();
7150    if projection_version != GRAPH_PROJECTION_VERSION {
7151        diagnostics.push(format!(
7152            "projection version mismatch: expected {} got {}",
7153            GRAPH_PROJECTION_VERSION, projection_version
7154        ));
7155    }
7156    if content_hash.is_none() {
7157        diagnostics.push("projection content hash is missing".to_string());
7158    }
7159    let fail_closed = !diagnostics.is_empty();
7160    Ok(GraphDbFreshnessReport {
7161        status: if fail_closed { "stale" } else { "current" }.to_string(),
7162        fail_closed,
7163        projection_version: Some(projection_version),
7164        content_hash,
7165        source_watermark,
7166        diagnostics,
7167    })
7168}
7169
7170fn graph_db_operator_next_commands(
7171    root: &Path,
7172    scope: Option<&str>,
7173    include_refresh: bool,
7174) -> Vec<String> {
7175    let mut commands = Vec::new();
7176    if include_refresh {
7177        commands.push(graph_db_refresh_command(root, scope));
7178    }
7179    commands.push(format!(
7180        "tsift graph-db --path {}{} doctor --json",
7181        shell_quote(root.to_string_lossy().as_ref()),
7182        graph_db_scope_arg(scope)
7183    ));
7184    commands.push(format!(
7185        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
7186        shell_quote(root.to_string_lossy().as_ref()),
7187        graph_db_scope_arg(scope)
7188    ));
7189    commands.push(format!(
7190        "tsift convex-sync {}{} --remote-snapshot --apply --json",
7191        shell_quote(root.to_string_lossy().as_ref()),
7192        graph_db_scope_arg(scope)
7193    ));
7194    commands
7195}
7196
7197pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
7198    match recovery {
7199        index::ReadOnlyRecovery::SnapshotFallback => {
7200            "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
7201        }
7202        index::ReadOnlyRecovery::SnapshotFallbackWal => {
7203            "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
7204        }
7205    }
7206}
7207
7208fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
7209    let mut stmt = conn.prepare(sql)?;
7210    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7211    let mut values = BTreeSet::new();
7212    for row in rows {
7213        values.insert(row?);
7214    }
7215    Ok(values)
7216}
7217
7218fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
7219    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
7220    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
7221    let mut columns = BTreeSet::new();
7222    for row in rows {
7223        columns.insert(row?);
7224    }
7225    Ok(columns)
7226}
7227
7228fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7229    let mut diagnostics = Vec::new();
7230    let user_version: i64 =
7231        conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
7232    if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
7233        diagnostics.push(format!(
7234            "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7235        ));
7236    } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
7237        diagnostics.push(format!(
7238            "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7239        ));
7240    }
7241
7242    let tables = sqlite_string_set(
7243        conn,
7244        "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
7245    )?;
7246    let required_tables = [
7247        (
7248            "graph_nodes",
7249            vec![
7250                "id",
7251                "kind",
7252                "label",
7253                "properties_json",
7254                "provenance_json",
7255                "freshness_json",
7256                "row_hash",
7257                "source_watermark",
7258            ],
7259        ),
7260        (
7261            "graph_edges",
7262            vec![
7263                "edge_key",
7264                "from_id",
7265                "to_id",
7266                "kind",
7267                "properties_json",
7268                "provenance_json",
7269                "freshness_json",
7270                "row_hash",
7271                "source_watermark",
7272            ],
7273        ),
7274        (
7275            "graph_projection_versions",
7276            vec![
7277                "scope",
7278                "projection_version",
7279                "content_hash",
7280                "source_watermark",
7281                "observed_at_unix",
7282            ],
7283        ),
7284        (
7285            "graph_tombstones",
7286            vec!["row_key", "row_kind", "deleted_at_unix"],
7287        ),
7288        ("graph_node_properties", vec!["node_id", "key", "value"]),
7289        ("graph_edge_properties", vec!["edge_key", "key", "value"]),
7290    ];
7291    for (table, required_columns) in required_tables {
7292        if !tables.contains(table) {
7293            diagnostics.push(format!("graph.db schema drift: missing table {table}"));
7294            continue;
7295        }
7296        let columns = sqlite_column_names(conn, table)?;
7297        for column in required_columns {
7298            if !columns.contains(column) {
7299                diagnostics.push(format!(
7300                    "graph.db schema drift: missing column {table}.{column}"
7301                ));
7302            }
7303        }
7304    }
7305
7306    let indexes = sqlite_string_set(
7307        conn,
7308        "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
7309    )?;
7310    for index in [
7311        "idx_graph_nodes_kind",
7312        "idx_graph_edges_from_kind",
7313        "idx_graph_edges_to_kind",
7314        "idx_graph_edges_edge_key",
7315        "idx_graph_node_properties_key_value_node",
7316        "idx_graph_edge_properties_key_value_edge",
7317    ] {
7318        if !indexes.contains(index) {
7319            diagnostics.push(format!("graph.db schema drift: missing index {index}"));
7320        }
7321    }
7322
7323    if tables.contains("graph_edges") {
7324        let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
7325        let rows = stmt.query_map([], |row| {
7326            Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
7327        })?;
7328        let mut fks = BTreeSet::new();
7329        for row in rows {
7330            fks.insert(row?);
7331        }
7332        for expected in [
7333            ("from_id".to_string(), "id".to_string()),
7334            ("to_id".to_string(), "id".to_string()),
7335        ] {
7336            if !fks.contains(&expected) {
7337                diagnostics.push(format!(
7338                    "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
7339                    expected.0, expected.1
7340                ));
7341            }
7342        }
7343    }
7344
7345    Ok(diagnostics)
7346}
7347
7348fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
7349    let mut stmt = conn.prepare(sql)?;
7350    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7351    let mut diagnostics = Vec::new();
7352    for row in rows {
7353        diagnostics.push(row?);
7354    }
7355    Ok(diagnostics)
7356}
7357
7358fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7359    let mut diagnostics = sqlite_query_diagnostics(
7360        conn,
7361        r#"
7362        SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
7363        FROM graph_nodes
7364        GROUP BY id
7365        HAVING COUNT(*) > 1
7366        ORDER BY id
7367        "#,
7368    )?;
7369    diagnostics.extend(sqlite_query_diagnostics(
7370        conn,
7371        r#"
7372        SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
7373        FROM graph_edges
7374        GROUP BY from_id, to_id, kind
7375        HAVING COUNT(*) > 1
7376        ORDER BY from_id, kind, to_id
7377        "#,
7378    )?);
7379    diagnostics.extend(sqlite_query_diagnostics(
7380        conn,
7381        r#"
7382        SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
7383        FROM graph_edges
7384        GROUP BY edge_key
7385        HAVING COUNT(*) > 1
7386        ORDER BY edge_key
7387        "#,
7388    )?);
7389    Ok(diagnostics)
7390}
7391
7392fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7393    sqlite_query_diagnostics(
7394        conn,
7395        r#"
7396        SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7397        FROM graph_edges e
7398        LEFT JOIN graph_nodes n ON n.id = e.from_id
7399        WHERE n.id IS NULL
7400        UNION ALL
7401        SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7402        FROM graph_edges e
7403        LEFT JOIN graph_nodes n ON n.id = e.to_id
7404        WHERE n.id IS NULL
7405        ORDER BY 1
7406        "#,
7407    )
7408}
7409
7410fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7411    let mut diagnostics = Vec::new();
7412    let mut node_stmt = conn.prepare(
7413        "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7414    )?;
7415    let node_rows = node_stmt.query_map([], |row| {
7416        Ok((
7417            row.get::<_, String>(0)?,
7418            row.get::<_, String>(1)?,
7419            row.get::<_, String>(2)?,
7420            row.get::<_, Option<String>>(3)?,
7421        ))
7422    })?;
7423    for row in node_rows {
7424        let (id, properties_json, provenance_json, freshness_json) = row?;
7425        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7426            diagnostics.push(format!(
7427                "graph_nodes {id} properties_json is invalid: {err}"
7428            ));
7429        }
7430        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7431            diagnostics.push(format!(
7432                "graph_nodes {id} provenance_json is invalid: {err}"
7433            ));
7434        }
7435        if let Some(freshness_json) = freshness_json
7436            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7437        {
7438            diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
7439        }
7440    }
7441
7442    let mut edge_stmt = conn.prepare(
7443        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7444    )?;
7445    let edge_rows = edge_stmt.query_map([], |row| {
7446        Ok((
7447            row.get::<_, String>(0)?,
7448            row.get::<_, String>(1)?,
7449            row.get::<_, String>(2)?,
7450            row.get::<_, String>(3)?,
7451            row.get::<_, String>(4)?,
7452            row.get::<_, String>(5)?,
7453            row.get::<_, Option<String>>(6)?,
7454        ))
7455    })?;
7456    for row in edge_rows {
7457        let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
7458            row?;
7459        let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
7460        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7461            diagnostics.push(format!(
7462                "graph_edges {edge} properties_json is invalid: {err}"
7463            ));
7464        }
7465        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7466            diagnostics.push(format!(
7467                "graph_edges {edge} provenance_json is invalid: {err}"
7468            ));
7469        }
7470        if let Some(freshness_json) = freshness_json
7471            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7472        {
7473            diagnostics.push(format!(
7474                "graph_edges {edge} freshness_json is invalid: {err}"
7475            ));
7476        }
7477    }
7478    Ok(diagnostics)
7479}
7480
7481fn sqlite_graph_projection_metadata_diagnostics(
7482    conn: &Connection,
7483    scope: Option<&str>,
7484) -> Result<Vec<String>> {
7485    let mut diagnostics = Vec::new();
7486    let scope_key = scope.unwrap_or("root");
7487    let version = conn
7488        .query_row(
7489            r#"
7490            SELECT projection_version, content_hash, source_watermark
7491            FROM graph_projection_versions
7492            WHERE scope = ?1
7493            "#,
7494            [scope_key],
7495            |row| {
7496                Ok((
7497                    row.get::<_, String>(0)?,
7498                    row.get::<_, Option<String>>(1)?,
7499                    row.get::<_, Option<String>>(2)?,
7500                ))
7501            },
7502        )
7503        .optional()?;
7504    let Some((projection_version, content_hash, _source_watermark)) = version else {
7505        diagnostics.push(format!(
7506            "graph projection metadata is missing for scope {scope_key}"
7507        ));
7508        return Ok(diagnostics);
7509    };
7510    if projection_version != GRAPH_PROJECTION_VERSION {
7511        diagnostics.push(format!(
7512            "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
7513        ));
7514    }
7515    if content_hash.is_none() {
7516        diagnostics.push("projection content hash is missing".to_string());
7517    }
7518
7519    let meta_id = graph_projection_meta_id(scope);
7520    let meta_properties = conn
7521        .query_row(
7522            "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
7523            (&meta_id, GRAPH_PROJECTION_META_KIND),
7524            |row| row.get::<_, String>(0),
7525        )
7526        .optional()?;
7527    let Some(meta_properties) = meta_properties else {
7528        diagnostics.push(format!("projection_meta node {meta_id} is missing"));
7529        return Ok(diagnostics);
7530    };
7531    let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
7532        .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
7533    if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
7534        diagnostics.push(format!(
7535            "projection_meta node {meta_id} has stale projection_version"
7536        ));
7537    }
7538    if properties.get("content_hash") != content_hash.as_ref() {
7539        diagnostics.push(format!(
7540            "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
7541        ));
7542    }
7543    Ok(diagnostics)
7544}
7545
7546pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
7547    let mut node_stmt = conn.prepare(
7548        "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7549    )?;
7550    let node_rows = node_stmt.query_map([], |row| {
7551        let properties_json: String = row.get(3)?;
7552        let provenance_json: String = row.get(4)?;
7553        let freshness_json: Option<String> = row.get(5)?;
7554        Ok((
7555            row.get::<_, String>(0)?,
7556            row.get::<_, String>(1)?,
7557            row.get::<_, String>(2)?,
7558            properties_json,
7559            provenance_json,
7560            freshness_json,
7561        ))
7562    })?;
7563    let mut nodes = Vec::new();
7564    for row in node_rows {
7565        let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
7566        nodes.push(ConvexNodeRow {
7567            external_id,
7568            kind,
7569            label,
7570            properties: serde_json::from_str(&properties_json)?,
7571            provenance: serde_json::from_str(&provenance_json)?,
7572            freshness: freshness_json
7573                .map(|value| serde_json::from_str(&value))
7574                .transpose()?,
7575        });
7576    }
7577
7578    let mut edge_stmt = conn.prepare(
7579        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7580    )?;
7581    let edge_rows = edge_stmt.query_map([], |row| {
7582        let properties_json: String = row.get(4)?;
7583        let provenance_json: String = row.get(5)?;
7584        let freshness_json: Option<String> = row.get(6)?;
7585        Ok((
7586            row.get::<_, String>(0)?,
7587            row.get::<_, String>(1)?,
7588            row.get::<_, String>(2)?,
7589            row.get::<_, String>(3)?,
7590            properties_json,
7591            provenance_json,
7592            freshness_json,
7593        ))
7594    })?;
7595    let mut edges = Vec::new();
7596    for row in edge_rows {
7597        let (
7598            edge_key,
7599            from_external_id,
7600            to_external_id,
7601            kind,
7602            properties_json,
7603            provenance_json,
7604            freshness_json,
7605        ) = row?;
7606        edges.push(ConvexEdgeRow {
7607            edge_key,
7608            from_external_id,
7609            to_external_id,
7610            kind,
7611            properties: serde_json::from_str(&properties_json)?,
7612            provenance: serde_json::from_str(&provenance_json)?,
7613            freshness: freshness_json
7614                .map(|value| serde_json::from_str(&value))
7615                .transpose()?,
7616        });
7617    }
7618    Ok(ConvexProjectionRows { nodes, edges })
7619}
7620
7621fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
7622    format!("{}.{}({})", index.table, index.name, index.fields.join(","))
7623}
7624
7625fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
7626    value
7627        .get("indexes")
7628        .or_else(|| value.get("requiredIndexes"))
7629        .or_else(|| {
7630            value
7631                .get("metadata")
7632                .and_then(|metadata| metadata.get("indexes"))
7633        })
7634}
7635
7636fn convex_snapshot_declared_indexes(
7637    value: &serde_json::Value,
7638) -> Result<Option<Vec<ConvexRequiredIndex>>> {
7639    convex_snapshot_index_value(value)
7640        .map(|indexes| {
7641            serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
7642                .context("parsing Convex snapshot index metadata")
7643        })
7644        .transpose()
7645}
7646
7647fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
7648    let required = convex_required_indexes();
7649    let Some(declared) = convex_snapshot_declared_indexes(value)? else {
7650        return Ok(vec![format!(
7651            "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
7652            required
7653                .iter()
7654                .map(convex_required_index_label)
7655                .collect::<Vec<_>>()
7656                .join(", ")
7657        )]);
7658    };
7659    let declared = declared.into_iter().collect::<BTreeSet<_>>();
7660    let missing = required
7661        .iter()
7662        .filter(|index| !declared.contains(*index))
7663        .map(convex_required_index_label)
7664        .collect::<Vec<_>>();
7665    if missing.is_empty() {
7666        Ok(Vec::new())
7667    } else {
7668        Ok(vec![format!(
7669            "Convex snapshot is missing required index metadata: {}",
7670            missing.join(", ")
7671        )])
7672    }
7673}
7674
7675pub(crate) fn load_convex_projection_snapshot_value(
7676    snapshot_path: &Path,
7677) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7678    let content = fs::read_to_string(snapshot_path).with_context(|| {
7679        format!(
7680            "reading Convex projection snapshot {}",
7681            snapshot_path.display()
7682        )
7683    })?;
7684    let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7685        format!(
7686            "parsing Convex projection snapshot {}",
7687            snapshot_path.display()
7688        )
7689    })?;
7690    let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7691        .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7692    Ok((rows, value))
7693}
7694
7695pub(crate) fn append_sqlite_graph_doctor_checks(
7696    report: &mut GraphDbDoctorReport,
7697    root: &Path,
7698    scope: Option<&str>,
7699    graph_db: &Path,
7700) -> Option<substrate::SqliteReadOnlyConnection> {
7701    let rebuild = graph_db_rebuild_command(root, scope);
7702    let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7703    if !graph_db.exists() {
7704        report.push_check(graph_db_doctor_check(
7705            "sqlite_graph_db_exists",
7706            vec![format!("graph.db is missing at {}", graph_db.display())],
7707            vec![rebuild],
7708        ));
7709        return None;
7710    }
7711    report.push_check(graph_db_doctor_check(
7712        "sqlite_graph_db_exists",
7713        Vec::new(),
7714        vec![rebuild.clone()],
7715    ));
7716
7717    let conn = match open_sqlite_graph_db_readonly(graph_db) {
7718        Ok(conn) => conn,
7719        Err(err) => {
7720            report.push_check(graph_db_doctor_check(
7721                "sqlite_graph_db_open",
7722                vec![err.to_string()],
7723                vec![backup_rebuild],
7724            ));
7725            return None;
7726        }
7727    };
7728    report.push_check(graph_db_doctor_check(
7729        "sqlite_graph_db_open",
7730        Vec::new(),
7731        vec![rebuild.clone()],
7732    ));
7733    if let Some(recovery) = conn.recovery() {
7734        report.push_check(GraphDbDoctorCheck {
7735            name: "sqlite_graph_db_read_recovery".to_string(),
7736            status: "recovered".to_string(),
7737            fail_closed: false,
7738            diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7739            repair_commands: Vec::new(),
7740        });
7741    }
7742
7743    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7744        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7745    report.push_check(graph_db_doctor_check(
7746        "sqlite_schema",
7747        schema_diagnostics,
7748        vec![backup_rebuild.clone()],
7749    ));
7750
7751    let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7752        .unwrap_or_else(|err| {
7753            vec![format!(
7754                "graph projection metadata inspection failed: {err}"
7755            )]
7756        });
7757    report.push_check(graph_db_doctor_check(
7758        "sqlite_projection_metadata",
7759        metadata_diagnostics,
7760        vec![rebuild.clone()],
7761    ));
7762
7763    let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7764        .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7765    report.push_check(graph_db_doctor_check(
7766        "sqlite_duplicate_ids",
7767        duplicate_diagnostics,
7768        vec![backup_rebuild.clone()],
7769    ));
7770
7771    let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7772        .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7773    report.push_check(graph_db_doctor_check(
7774        "sqlite_orphan_edges",
7775        orphan_diagnostics,
7776        vec![rebuild.clone()],
7777    ));
7778
7779    let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7780        .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7781    report.push_check(graph_db_doctor_check(
7782        "sqlite_row_json",
7783        json_diagnostics,
7784        vec![backup_rebuild],
7785    ));
7786
7787    let tombstone_diagnostics =
7788        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7789            .unwrap_or_else(|err| {
7790                vec![format!(
7791                    "graph tombstone retention inspection failed: {err}"
7792                )]
7793            });
7794    report.push_check(GraphDbDoctorCheck {
7795        name: "sqlite_tombstone_retention".to_string(),
7796        status: if tombstone_diagnostics.is_empty() {
7797            "ok".to_string()
7798        } else {
7799            "warning".to_string()
7800        },
7801        fail_closed: false,
7802        diagnostics: tombstone_diagnostics,
7803        repair_commands: Vec::new(),
7804    });
7805    let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7806        Ok(counts) => {
7807            let policy = graph_db_compaction_policy(root, scope, &counts, false);
7808            GraphDbDoctorCheck {
7809                name: "sqlite_compaction_policy".to_string(),
7810                status: policy.status.clone(),
7811                fail_closed: false,
7812                diagnostics: policy.proof,
7813                repair_commands: if policy.status == "recommended" {
7814                    policy.recommendations
7815                } else {
7816                    Vec::new()
7817                },
7818            }
7819        }
7820        Err(err) => GraphDbDoctorCheck {
7821            name: "sqlite_compaction_policy".to_string(),
7822            status: "warning".to_string(),
7823            fail_closed: false,
7824            diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7825            repair_commands: Vec::new(),
7826        },
7827    };
7828    report.push_check(compaction_check);
7829
7830    Some(conn)
7831}
7832
7833pub(crate) fn append_convex_snapshot_doctor_checks(
7834    report: &mut GraphDbDoctorReport,
7835    root: &Path,
7836    scope: Option<&str>,
7837    local_rows: Option<&ConvexProjectionRows>,
7838    snapshot_path: Option<&Path>,
7839) {
7840    let repair = convex_refresh_command(root, scope);
7841    let Some(snapshot_path) = snapshot_path else {
7842        report.push_check(graph_db_doctor_check(
7843            "convex_snapshot_present",
7844            vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7845            vec![format!(
7846                "tsift convex-sync {}{} --json > convex-rows.json",
7847                shell_quote(root.to_string_lossy().as_ref()),
7848                graph_db_scope_arg(scope)
7849            )],
7850        ));
7851        return;
7852    };
7853    report.push_check(graph_db_doctor_check(
7854        "convex_snapshot_present",
7855        Vec::new(),
7856        vec![repair.clone()],
7857    ));
7858
7859    let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7860        Ok(snapshot) => snapshot,
7861        Err(err) => {
7862            report.push_check(graph_db_doctor_check(
7863                "convex_snapshot_parse",
7864                vec![err.to_string()],
7865                vec![repair],
7866            ));
7867            return;
7868        }
7869    };
7870    report.push_check(graph_db_doctor_check(
7871        "convex_snapshot_parse",
7872        Vec::new(),
7873        vec![repair.clone()],
7874    ));
7875
7876    let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7877    report.push_check(graph_db_doctor_check(
7878        "convex_snapshot_rows",
7879        row_diagnostics,
7880        vec![repair.clone()],
7881    ));
7882
7883    let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7884        .unwrap_or_else(|err| vec![err.to_string()]);
7885    report.required_indexes = convex_required_indexes();
7886    report.push_check(graph_db_doctor_check(
7887        "convex_required_indexes",
7888        index_diagnostics,
7889        vec![
7890            "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7891                .to_string(),
7892        ],
7893    ));
7894
7895    if let Some(local_rows) = local_rows {
7896        let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7897        report.push_check(graph_db_doctor_check(
7898            "convex_projection_freshness",
7899            freshness.diagnostics,
7900            vec![repair],
7901        ));
7902    } else {
7903        report.push_check(graph_db_doctor_check(
7904            "convex_projection_freshness",
7905            vec![
7906                "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7907                    .to_string(),
7908            ],
7909            vec![graph_db_rebuild_command(root, scope)],
7910        ));
7911    }
7912}
7913
7914fn graph_db_convex_snapshot_doctor_command(
7915    root: &Path,
7916    scope: Option<&str>,
7917    snapshot_path: &Path,
7918) -> String {
7919    format!(
7920        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7921        shell_quote(root.to_string_lossy().as_ref()),
7922        graph_db_scope_arg(scope),
7923        shell_quote(snapshot_path.to_string_lossy().as_ref())
7924    )
7925}
7926
7927fn graph_db_convex_snapshot_read_command(
7928    root: &Path,
7929    scope: Option<&str>,
7930    snapshot_path: &Path,
7931) -> String {
7932    format!(
7933        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7934        shell_quote(root.to_string_lossy().as_ref()),
7935        graph_db_scope_arg(scope),
7936        shell_quote(snapshot_path.to_string_lossy().as_ref())
7937    )
7938}
7939
7940fn convex_sync_snapshot_diff_command(
7941    root: &Path,
7942    scope: Option<&str>,
7943    snapshot_path: &Path,
7944) -> String {
7945    format!(
7946        "tsift convex-sync {}{} --snapshot {} --json",
7947        shell_quote(root.to_string_lossy().as_ref()),
7948        graph_db_scope_arg(scope),
7949        shell_quote(snapshot_path.to_string_lossy().as_ref())
7950    )
7951}
7952
7953pub(crate) struct GraphDbDriftInput<'a> {
7954    root: &'a Path,
7955    scope: Option<&'a str>,
7956    graph_db: &'a Path,
7957    snapshot_path: &'a Path,
7958    local: &'a ConvexProjectionRows,
7959    snapshot: &'a ConvexProjectionRows,
7960    snapshot_value: &'a serde_json::Value,
7961    warnings: Vec<String>,
7962}
7963
7964pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7965    let GraphDbDriftInput {
7966        root,
7967        scope,
7968        graph_db,
7969        snapshot_path,
7970        local,
7971        snapshot,
7972        snapshot_value,
7973        warnings,
7974    } = input;
7975    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7976    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7977        convex_rows_diff(local, Some(snapshot));
7978    let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7979    let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7980        .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7981    let local_hash = freshness.local_hash.clone();
7982    let snapshot_hash = freshness.snapshot_hash.clone();
7983    let stale_nodes = freshness.stale_nodes.clone();
7984    let stale_edges = freshness.stale_edges.clone();
7985
7986    let duplicate_failures = row_diagnostics
7987        .iter()
7988        .filter(|diagnostic| diagnostic.contains("duplicate"))
7989        .count();
7990    let orphan_failures = row_diagnostics
7991        .iter()
7992        .filter(|diagnostic| diagnostic.contains("references missing"))
7993        .count();
7994    let missing_required_indexes = index_diagnostics.len();
7995    let stale_projection_metadata =
7996        usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7997    let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7998    let has_drift = freshness.fail_closed
7999        || !node_upserts.is_empty()
8000        || !edge_upserts.is_empty()
8001        || !node_tombstones.is_empty()
8002        || !edge_tombstones.is_empty();
8003    let status = if hard_failures > 0 {
8004        "fail_closed"
8005    } else if has_drift {
8006        "drift"
8007    } else {
8008        "current"
8009    }
8010    .to_string();
8011
8012    let mut diagnostics = Vec::new();
8013    diagnostics.extend(row_diagnostics);
8014    diagnostics.extend(index_diagnostics);
8015    diagnostics.extend(freshness.diagnostics.clone());
8016    if has_drift {
8017        diagnostics.push(format!(
8018            "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
8019            node_upserts.len(),
8020            edge_upserts.len(),
8021            node_tombstones.len(),
8022            edge_tombstones.len()
8023        ));
8024    }
8025
8026    let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
8027        root,
8028        scope,
8029        snapshot_path,
8030    )];
8031    if status == "current" {
8032        next_commands.push(graph_db_convex_snapshot_read_command(
8033            root,
8034            scope,
8035            snapshot_path,
8036        ));
8037    } else {
8038        next_commands.push(convex_sync_snapshot_diff_command(
8039            root,
8040            scope,
8041            snapshot_path,
8042        ));
8043        next_commands.push(convex_refresh_command(root, scope));
8044    }
8045
8046    GraphDbDriftReport {
8047        root: root.to_string_lossy().to_string(),
8048        scope: scope.map(str::to_string),
8049        graph_db: graph_db.to_string_lossy().to_string(),
8050        convex_snapshot: snapshot_path.to_string_lossy().to_string(),
8051        status: status.clone(),
8052        graph_reads_allowed: status == "current",
8053        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
8054        local_hash,
8055        snapshot_hash,
8056        summary: GraphDbDriftSummary {
8057            node_upserts: node_upserts.len(),
8058            edge_upserts: edge_upserts.len(),
8059            node_tombstones: node_tombstones.len(),
8060            edge_tombstones: edge_tombstones.len(),
8061            stale_nodes: stale_nodes.len(),
8062            stale_edges: stale_edges.len(),
8063            stale_projection_metadata,
8064            duplicate_failures,
8065            orphan_failures,
8066            missing_required_indexes,
8067        },
8068        node_upserts: node_upserts
8069            .into_iter()
8070            .map(|row| row.external_id)
8071            .collect(),
8072        edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
8073        node_tombstones,
8074        edge_tombstones,
8075        stale_nodes,
8076        stale_edges,
8077        diagnostics,
8078        next_commands,
8079        required_indexes: convex_required_indexes(),
8080        warnings,
8081    }
8082}
8083
8084pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
8085    println!(
8086        "graph-db drift status: {} reads_allowed: {}",
8087        report.status, report.graph_reads_allowed
8088    );
8089    println!("graph_db: {}", report.graph_db);
8090    println!("convex_snapshot: {}", report.convex_snapshot);
8091    println!(
8092        "upserts: {} node(s), {} edge(s)",
8093        report.summary.node_upserts, report.summary.edge_upserts
8094    );
8095    println!(
8096        "tombstones: {} node(s), {} edge(s)",
8097        report.summary.node_tombstones, report.summary.edge_tombstones
8098    );
8099    for diagnostic in &report.diagnostics {
8100        println!("diagnostic: {diagnostic}");
8101    }
8102    for command in &report.next_commands {
8103        println!("next: {command}");
8104    }
8105}
8106
8107pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
8108    println!(
8109        "graph-db doctor backend: {} status: {}",
8110        report.backend, report.status
8111    );
8112    println!("graph_db: {}", report.graph_db);
8113    if let Some(snapshot) = &report.convex_snapshot {
8114        println!("convex_snapshot: {snapshot}");
8115    }
8116    for check in &report.checks {
8117        println!("check: {} {}", check.name, check.status);
8118        for diagnostic in &check.diagnostics {
8119            println!("  diagnostic: {diagnostic}");
8120        }
8121    }
8122    for command in &report.repair_commands {
8123        println!("repair: {command}");
8124    }
8125}
8126
8127pub(crate) fn graph_db_operator_report_from_disk(
8128    root: &Path,
8129    scope: Option<&str>,
8130    graph_db: &Path,
8131    operation: &str,
8132    refresh: Option<GraphDbRefreshSummary>,
8133    warnings: Vec<String>,
8134) -> Result<GraphDbOperatorReport> {
8135    if !graph_db.exists() {
8136        let next_commands = graph_db_operator_next_commands(root, scope, true);
8137        let counts = GraphDbOperatorCounts {
8138            nodes: 0,
8139            edges: 0,
8140            tombstones: GraphDbTombstoneCounts {
8141                nodes: 0,
8142                edges: 0,
8143                total: 0,
8144            },
8145            file_size_bytes: None,
8146            freelist_bytes: None,
8147        };
8148        return Ok(GraphDbOperatorReport {
8149            root: root.to_string_lossy().to_string(),
8150            scope: scope.map(str::to_string),
8151            graph_db: graph_db.to_string_lossy().to_string(),
8152            operation: operation.to_string(),
8153            status: "missing".to_string(),
8154            materialized: false,
8155            freshness: GraphDbFreshnessReport {
8156                status: "missing".to_string(),
8157                fail_closed: true,
8158                projection_version: None,
8159                content_hash: None,
8160                source_watermark: None,
8161                diagnostics: vec![
8162                    "graph.db is missing; run graph-db refresh before trusting graph reads"
8163                        .to_string(),
8164                ],
8165            },
8166            readiness: graph_effectiveness_blocked(
8167                "graph_db_missing",
8168                vec![
8169                    "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
8170                ],
8171                next_commands.clone(),
8172            ),
8173            counts: counts.clone(),
8174            refresh,
8175            compaction: graph_db_compaction_policy(root, scope, &counts, false),
8176            recovery: None,
8177            next_commands,
8178            warnings,
8179        });
8180    }
8181
8182    let conn = open_sqlite_graph_db_readonly(graph_db)?;
8183    let recovery = conn.recovery();
8184    let mut warnings = warnings;
8185    if let Some(recovery) = recovery {
8186        warnings.push(graph_db_read_recovery_diagnostic(recovery));
8187    }
8188    let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
8189    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
8190        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
8191    if !schema_diagnostics.is_empty() {
8192        freshness.diagnostics.extend(schema_diagnostics);
8193        freshness.fail_closed = true;
8194        freshness.status = "stale".to_string();
8195    }
8196    let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
8197    let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
8198    warnings.extend(
8199        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
8200            .unwrap_or_else(|err| {
8201                vec![format!(
8202                    "graph tombstone retention inspection failed: {err}"
8203                )]
8204            }),
8205    );
8206    let status = if freshness.fail_closed {
8207        "stale"
8208    } else {
8209        "current"
8210    }
8211    .to_string();
8212
8213    Ok(GraphDbOperatorReport {
8214        root: root.to_string_lossy().to_string(),
8215        scope: scope.map(str::to_string),
8216        graph_db: graph_db.to_string_lossy().to_string(),
8217        operation: operation.to_string(),
8218        status,
8219        materialized: true,
8220        freshness,
8221        readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
8222        compaction: graph_db_compaction_policy(root, scope, &counts, false),
8223        counts,
8224        refresh,
8225        recovery,
8226        next_commands: graph_db_operator_next_commands(root, scope, false),
8227        warnings,
8228    })
8229}
8230
8231fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
8232    println!(
8233        "graph-db {} status: {} materialized: {}",
8234        report.operation, report.status, report.materialized
8235    );
8236    println!("graph_db: {}", report.graph_db);
8237    println!(
8238        "projection: version={} hash={} watermark={}",
8239        report
8240            .freshness
8241            .projection_version
8242            .as_deref()
8243            .unwrap_or("<missing>"),
8244        report
8245            .freshness
8246            .content_hash
8247            .as_deref()
8248            .unwrap_or("<missing>"),
8249        report
8250            .freshness
8251            .source_watermark
8252            .as_deref()
8253            .unwrap_or("<missing>")
8254    );
8255    println!(
8256        "rows: {} node(s), {} edge(s), {} tombstone(s)",
8257        report.counts.nodes, report.counts.edges, report.counts.tombstones.total
8258    );
8259    println!(
8260        "readiness: {} reason: {} fail_closed: {}",
8261        report.readiness.status, report.readiness.reason, report.readiness.fail_closed
8262    );
8263    if let Some(file_size) = report.counts.file_size_bytes {
8264        println!(
8265            "storage: {} byte(s), {} free byte(s)",
8266            file_size,
8267            report.counts.freelist_bytes.unwrap_or(0)
8268        );
8269    }
8270    if let Some(refresh) = &report.refresh {
8271        println!(
8272            "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
8273            refresh.tombstoned_nodes, refresh.tombstoned_edges
8274        );
8275        println!(
8276            "delta: {} node upsert(s), {} edge upsert(s), {} property row upsert(s), {} unchanged node(s), {} unchanged edge(s), {} unchanged property row(s), {} deleted property row(s), {} pruned tombstone(s)",
8277            refresh.upserted_nodes,
8278            refresh.upserted_edges,
8279            refresh.upserted_properties,
8280            refresh.unchanged_nodes,
8281            refresh.unchanged_edges,
8282            refresh.unchanged_properties,
8283            refresh.deleted_properties,
8284            refresh.pruned_tombstones
8285        );
8286    }
8287    println!(
8288        "compaction: {} tombstone_scan_rows={} live_rows={}",
8289        report.compaction.status,
8290        report.compaction.tombstone_scan_rows,
8291        report.compaction.live_rows
8292    );
8293    for proof in &report.compaction.proof {
8294        println!("compaction proof: {proof}");
8295    }
8296    if let Some(recovery) = report.recovery {
8297        println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
8298    }
8299    for diagnostic in &report.freshness.diagnostics {
8300        println!("diagnostic: {diagnostic}");
8301    }
8302    for diagnostic in &report.readiness.diagnostics {
8303        println!("readiness diagnostic: {diagnostic}");
8304    }
8305    for warning in &report.warnings {
8306        println!("warning: {warning}");
8307    }
8308    for command in &report.readiness.next_commands {
8309        println!("readiness next: {command}");
8310    }
8311    for command in &report.next_commands {
8312        println!("next: {command}");
8313    }
8314}
8315
8316pub(crate) fn print_graph_db_operator_report(
8317    report: &GraphDbOperatorReport,
8318    format: OutputFormat,
8319) -> Result<()> {
8320    if format.json_output {
8321        print_json_or_envelope(
8322            report,
8323            &format,
8324            "graph-db",
8325            &report.operation,
8326            ToolEnvelopeSummary {
8327                text: format!(
8328                    "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
8329                    report.operation,
8330                    report.status,
8331                    report.counts.nodes,
8332                    report.counts.edges,
8333                    report.counts.tombstones.total
8334                ),
8335                metrics: vec![
8336                    envelope_metric("operation", &report.operation),
8337                    envelope_metric("status", &report.status),
8338                    envelope_metric("nodes", report.counts.nodes),
8339                    envelope_metric("edges", report.counts.edges),
8340                    envelope_metric("tombstones", report.counts.tombstones.total),
8341                    envelope_metric("compaction", &report.compaction.status),
8342                    envelope_metric("readiness", &report.readiness.status),
8343                ],
8344            },
8345            false,
8346            report.next_commands.clone(),
8347        )
8348    } else {
8349        print_graph_db_operator_human(report);
8350        Ok(())
8351    }
8352}
8353
8354fn status_run_command_without_notes(run: &str) -> &str {
8355    run.split_once("  (")
8356        .map(|(command, _)| command)
8357        .unwrap_or(run)
8358}
8359
8360fn status_summarize_extract_command(run: &str) -> &str {
8361    let run = status_run_command_without_notes(run);
8362    run.split(" && ")
8363        .find(|command| command.contains("summarize --extract"))
8364        .unwrap_or(run)
8365}
8366
8367fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
8368    report
8369        .recommendations
8370        .run
8371        .as_deref()
8372        .filter(|command| command.contains("summarize --extract"))
8373        .map(status_summarize_extract_command)
8374        .unwrap_or("tsift summarize --extract .")
8375        .to_string()
8376}
8377
8378fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
8379    let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8380    readiness.diagnostics.push(format!(
8381        "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
8382    ));
8383    readiness
8384}
8385
8386fn graph_db_semantic_readiness(
8387    root: &Path,
8388    scope: Option<&str>,
8389    semantic_row_count: Option<usize>,
8390) -> GraphEffectivenessReadiness {
8391    if let Some(row_count) = semantic_row_count
8392        && row_count > 0
8393    {
8394        return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
8395    }
8396
8397    let report = match status::check_status(root) {
8398        Ok(report) => report,
8399        Err(err) => {
8400            return graph_effectiveness_blocked(
8401                "status_check_unavailable",
8402                vec![format!(
8403                    "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
8404                )],
8405                vec![graph_db_refresh_command(root, scope)],
8406            );
8407        }
8408    };
8409
8410    match &report.summaries {
8411        status::SummaryStatus::Available {
8412            cached_files,
8413            total_indexed_files,
8414            coverage_pct,
8415            ..
8416        } => {
8417            let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8418            readiness.diagnostics.push(format!(
8419                "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
8420            ));
8421            readiness
8422        }
8423        status::SummaryStatus::None { .. } => {
8424            let summarize = graph_db_status_summarize_command(&report);
8425            let index_command = report
8426                .recommendations
8427                .run
8428                .as_deref()
8429                .filter(|cmd| cmd.contains("index"))
8430                .map(str::to_string);
8431            let mut repair = Vec::new();
8432            if let Some(cmd) = index_command {
8433                repair.push(cmd);
8434            }
8435            repair.push(summarize.clone());
8436            repair.push(graph_db_refresh_command(root, scope));
8437            graph_effectiveness_blocked(
8438                "summary_cache_empty",
8439                vec![format!(
8440                    "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8441                    summarize,
8442                    root.display(),
8443                    graph_db_refresh_command(root, scope)
8444                )],
8445                repair,
8446            )
8447        }
8448        status::SummaryStatus::Unavailable => {
8449            let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
8450            let summarize = "tsift summarize --extract .".to_string();
8451            repair.push(summarize);
8452            repair.push(graph_db_refresh_command(root, scope));
8453            graph_effectiveness_blocked(
8454                "summary_cache_unavailable",
8455                vec![
8456                    "summary cache unavailable because the source index is missing; build the index, extract summaries, and refresh the graph before relying on semantic graph evidence".to_string(),
8457                ],
8458                repair,
8459            )
8460        }
8461    }
8462}
8463
8464pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
8465    let report = match status::check_status(root) {
8466        Ok(report) => report,
8467        Err(err) => {
8468            return vec![format!(
8469                "status check unavailable after graph-db refresh: {err:#}"
8470            )];
8471        }
8472    };
8473
8474    let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8475        Some(graph_db_status_summarize_command(&report))
8476    } else {
8477        None
8478    };
8479    let mut warnings = report.reminders;
8480    if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8481        let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
8482        warnings.push(format!(
8483            "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8484            run,
8485            root.display(),
8486            graph_db_refresh_command(root, scope)
8487        ));
8488    }
8489    dedupe_preserve_order(warnings)
8490}
8491
8492pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
8493    println!(
8494        "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
8495        report.applied, report.pruned_tombstones, report.reclaimed_bytes
8496    );
8497    println!("graph_db: {}", report.graph_db);
8498    println!(
8499        "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8500        report.counts_before.nodes,
8501        report.counts_before.edges,
8502        report.counts_before.tombstones.total,
8503        report.counts_before.file_size_bytes.unwrap_or(0),
8504        report.counts_before.freelist_bytes.unwrap_or(0)
8505    );
8506    println!(
8507        "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8508        report.counts_after.nodes,
8509        report.counts_after.edges,
8510        report.counts_after.tombstones.total,
8511        report.counts_after.file_size_bytes.unwrap_or(0),
8512        report.counts_after.freelist_bytes.unwrap_or(0)
8513    );
8514    for proof in &report.compaction_after.proof {
8515        println!("proof: {proof}");
8516    }
8517    for warning in &report.warnings {
8518        println!("warning: {warning}");
8519    }
8520    for command in &report.next_commands {
8521        println!("next: {command}");
8522    }
8523}
8524
8525fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
8526    raw.iter()
8527        .map(|value| {
8528            let (key, filter_value) = value
8529                .split_once('=')
8530                .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
8531            let key = key.trim();
8532            let filter_value = filter_value.trim();
8533            if key.is_empty() || filter_value.is_empty() {
8534                bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
8535            }
8536            Ok(GraphDbPropertyFilter {
8537                key: key.to_string(),
8538                value: filter_value.to_string(),
8539            })
8540        })
8541        .collect()
8542}
8543
8544fn graph_db_query_options(
8545    cursor: Option<String>,
8546    limit: Option<usize>,
8547    property_filters: &[String],
8548) -> Result<GraphDbQueryOptions> {
8549    Ok(GraphDbQueryOptions {
8550        cursor,
8551        limit: limit.filter(|limit| *limit > 0),
8552        property_filters: parse_graph_db_property_filters(property_filters)?,
8553    })
8554}
8555
8556fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
8557    GraphQueryOptions {
8558        cursor: options.cursor.clone(),
8559        limit: options.limit,
8560        property_filters: options
8561            .property_filters
8562            .iter()
8563            .map(|filter| GraphPropertyFilter {
8564                key: filter.key.clone(),
8565                value: filter.value.clone(),
8566            })
8567            .collect(),
8568    }
8569}
8570
8571fn graph_db_page_report_from_store(
8572    page: GraphQueryPage,
8573    property_filters: Vec<GraphDbPropertyFilter>,
8574) -> GraphDbPageReport {
8575    GraphDbPageReport {
8576        cursor: page.cursor,
8577        limit: page.limit,
8578        next_cursor: page.next_cursor,
8579        returned_nodes: page.returned_nodes,
8580        returned_edges: page.returned_edges,
8581        truncated: page.truncated,
8582        property_filters,
8583        diagnostics: page.diagnostics,
8584    }
8585}
8586
8587fn graph_db_neighborhood_ranking_gate(
8588    ranked_neighbor_cap: usize,
8589) -> GraphDbNeighborhoodRankingGate {
8590    GraphDbNeighborhoodRankingGate {
8591        status: "held_default_order_unchanged".to_string(),
8592        ranked_output_default: false,
8593        default_order: "stable_node_id".to_string(),
8594        default_change_gate: "community_search_quality_metrics".to_string(),
8595        required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
8596            .iter()
8597            .map(|workload| (*workload).to_string())
8598            .collect(),
8599        required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
8600            .iter()
8601            .map(|metric| (*metric).to_string())
8602            .collect(),
8603        max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
8604        min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
8605        min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
8606        min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
8607        diagnostics: vec![
8608            "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
8609            format!(
8610                "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
8611            ),
8612            "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
8613        ],
8614    }
8615}
8616
8617fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
8618    match limit {
8619        Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
8620        Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
8621    }
8622}
8623
8624fn graph_db_ranked_neighbors(
8625    center_id: &str,
8626    nodes: &[SubstrateGraphNode],
8627    edges: &[SubstrateGraphEdge],
8628    cap: usize,
8629) -> Vec<GraphDbRankedNeighbor> {
8630    resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
8631}
8632
8633fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
8634    center_id: &str,
8635    depth: usize,
8636    edge_kind: Option<&str>,
8637    limit: Option<usize>,
8638    unranked_nodes: &[SubstrateGraphNode],
8639    unranked_edges: &[SubstrateGraphEdge],
8640    store: &S,
8641) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
8642    use std::time::Instant;
8643    let max_nodes = match limit {
8644        Some(0) | None => 200,
8645        Some(n) => n.clamp(10, 500),
8646    };
8647    let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
8648        .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
8649    if let Some(kind) = edge_kind {
8650        options = options.with_edge_kind(kind);
8651    }
8652    let start = Instant::now();
8653    let result = store.ranked_neighborhood(center_id, &options)?;
8654    let latency = start.elapsed().as_micros();
8655    let Some(ranked) = result else {
8656        return Ok(None);
8657    };
8658    let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8659    let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8660    let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8661    let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8662        0.0
8663    } else {
8664        (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8665    };
8666    let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8667        let mut name_count = BTreeMap::<&str, usize>::new();
8668        for n in nodes {
8669            *name_count.entry(&n.label).or_default() += 1;
8670        }
8671        name_count.values().filter(|&&c| c > 1).count()
8672    };
8673    let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8674        if nodes.is_empty() {
8675            return 100.0;
8676        }
8677        let with_handle = nodes
8678            .iter()
8679            .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8680            .count();
8681        (with_handle as f64 / nodes.len() as f64) * 100.0
8682    };
8683    let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8684        if nodes.is_empty() {
8685            return 0.0;
8686        }
8687        let semantic_kinds = [
8688            "semantic_concept",
8689            "semantic_entity",
8690            "symbol",
8691            "file",
8692            "source_handle",
8693        ];
8694        let useful = nodes
8695            .iter()
8696            .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8697            .count();
8698        let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8699        let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8700        (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8701            / nodes.len() as f64
8702    };
8703    let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8704        let edge_pairs: Vec<(String, String)> = ranked
8705            .edges
8706            .iter()
8707            .map(|e| (e.from_id.clone(), e.to_id.clone()))
8708            .collect();
8709        let cr = tsift_graph::detect_communities(&edge_pairs);
8710        let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8711        let mut fully_kept = 0usize;
8712        let mut partially_pruned = 0usize;
8713        let mut fully_pruned = 0usize;
8714        let mut pruned_kinds = BTreeSet::new();
8715        let mut pruned_labels = Vec::new();
8716        for comm in &cr.communities {
8717            let kept_in_comm: Vec<&str> = comm
8718                .members
8719                .iter()
8720                .filter(|m| kept_labels.contains(m.name.as_str()))
8721                .map(|m| m.name.as_str())
8722                .collect();
8723            if kept_in_comm.len() == comm.members.len() {
8724                fully_kept += 1;
8725            } else if kept_in_comm.is_empty() {
8726                fully_pruned += 1;
8727                for m in &comm.members {
8728                    if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8729                        pruned_kinds.insert(n.kind.clone());
8730                    }
8731                    pruned_labels.push(m.name.clone());
8732                }
8733            } else {
8734                partially_pruned += 1;
8735            }
8736        }
8737        pruned_labels.truncate(5);
8738        Some(CommunityTruncationSummary {
8739            total_communities: cr.communities.len(),
8740            fully_kept,
8741            partially_pruned,
8742            fully_pruned,
8743            pruned_community_kinds: pruned_kinds.into_iter().collect(),
8744            pruned_community_top_labels: pruned_labels,
8745        })
8746    } else {
8747        None
8748    };
8749    Ok(Some(GraphDbRankedNeighborhoodComparison {
8750        traversal_nodes: ranked.nodes.len(),
8751        traversal_edges: ranked.edges.len(),
8752        pruned_count: ranked.pruned_count,
8753        total_discovered: ranked.total_discovered,
8754        latency_micros: latency,
8755        overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8756        useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8757            / 1000.0,
8758        useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8759            .round()
8760            / 1000.0,
8761        duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8762        duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8763        handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8764        handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8765            / 100.0,
8766        community_truncation_summary,
8767        diagnostics: vec![
8768            format!(
8769                "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8770                ranked.nodes.len(),
8771                ranked.edges.len(),
8772                ranked.pruned_count,
8773                ranked.total_discovered,
8774                latency
8775            ),
8776            format!(
8777                "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8778                overlap_pct,
8779                overlap_count,
8780                unranked_ids.len(),
8781                ranked_ids.len()
8782            ),
8783            "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8784        ],
8785    }))
8786}
8787
8788struct GraphDbBudgetedSubgraph {
8789    nodes: Vec<SubstrateGraphNode>,
8790    edges: Vec<SubstrateGraphEdge>,
8791    report: GraphDbOutputBudgetReport,
8792    truncated: bool,
8793    next_cursor: Option<String>,
8794}
8795
8796const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8797const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8798const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8799
8800fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8801    match limit {
8802        Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8803        Some(limit) => limit
8804            .saturating_mul(320)
8805            .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8806    }
8807}
8808
8809fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8810    if matches!(limit, Some(0) | None) {
8811        return match kind {
8812            "source_handle" => 10,
8813            "worker_context" | "worker_result" => 8,
8814            "semantic_concept" | "semantic_entity" => 10,
8815            "file" | "symbol" | "route" => 12,
8816            _ => 8,
8817        };
8818    }
8819    let base = limit.unwrap_or(0).max(1);
8820    match kind {
8821        "source_handle" => base.saturating_add(4),
8822        "worker_context" | "worker_result" => base.saturating_add(2),
8823        "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8824        "file" | "symbol" | "route" => base.saturating_add(4),
8825        _ => base.saturating_add(1),
8826    }
8827}
8828
8829fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8830    if matches!(limit, Some(0) | None) {
8831        return match kind {
8832            "mentions" | "mentions_concept" | "mentions_entity" => 24,
8833            "semantic_relation" | "calls" | "defines" => 20,
8834            _ => 16,
8835        };
8836    }
8837    let base = limit.unwrap_or(0).max(1);
8838    match kind {
8839        "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8840        "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8841        _ => base.saturating_add(2),
8842    }
8843}
8844
8845fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8846    serde_json::to_vec(value)
8847        .map(|bytes| bytes.len().div_ceil(4).max(1))
8848        .unwrap_or(1)
8849}
8850
8851fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8852    let mut parts = vec![node.kind.clone(), node.label.clone()];
8853    for key in [
8854        "detail",
8855        "description",
8856        "source_ref",
8857        "path",
8858        "source_file",
8859        "source_symbol",
8860        "text_preview",
8861    ] {
8862        if let Some(value) = node.properties.get(key) {
8863            parts.push(value.clone());
8864        }
8865    }
8866    parts.join(" ")
8867}
8868
8869fn graph_db_semantic_scores_for_query(
8870    query: Option<&str>,
8871    nodes: &[SubstrateGraphNode],
8872) -> BTreeMap<String, f64> {
8873    let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8874        return BTreeMap::new();
8875    };
8876    let query_embedding = semantic_embedding(query);
8877    nodes
8878        .iter()
8879        .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8880        .filter_map(|node| {
8881            let embedding = node
8882                .properties
8883                .get("embedding")
8884                .and_then(|value| parse_semantic_embedding_property(value))?;
8885            Some((
8886                node.id.clone(),
8887                semantic_cosine(&query_embedding, &embedding),
8888            ))
8889        })
8890        .collect()
8891}
8892
8893fn graph_db_depth_by_id(
8894    origin_ids: &[String],
8895    edges: &[SubstrateGraphEdge],
8896) -> BTreeMap<String, usize> {
8897    let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8898    for edge in edges {
8899        adjacency
8900            .entry(edge.from_id.clone())
8901            .or_default()
8902            .push(edge.to_id.clone());
8903        adjacency
8904            .entry(edge.to_id.clone())
8905            .or_default()
8906            .push(edge.from_id.clone());
8907    }
8908
8909    let mut depth_by_id = BTreeMap::<String, usize>::new();
8910    let mut queue = VecDeque::<String>::new();
8911    for origin in origin_ids {
8912        if depth_by_id.insert(origin.clone(), 0).is_none() {
8913            queue.push_back(origin.clone());
8914        }
8915    }
8916    while let Some(current) = queue.pop_front() {
8917        let depth = depth_by_id.get(&current).copied().unwrap_or(0);
8918        for next in adjacency.get(&current).into_iter().flatten() {
8919            if depth_by_id.contains_key(next) {
8920                continue;
8921            }
8922            depth_by_id.insert(next.clone(), depth.saturating_add(1));
8923            queue.push_back(next.clone());
8924        }
8925    }
8926    depth_by_id
8927}
8928
8929fn graph_db_source_covered_ids(
8930    nodes: &[SubstrateGraphNode],
8931    edges: &[SubstrateGraphEdge],
8932) -> BTreeSet<String> {
8933    let source_ids = nodes
8934        .iter()
8935        .filter(|node| node.kind == "source_handle")
8936        .map(|node| node.id.as_str())
8937        .collect::<BTreeSet<_>>();
8938    let mut covered = source_ids
8939        .iter()
8940        .map(|id| (*id).to_string())
8941        .collect::<BTreeSet<_>>();
8942    for edge in edges {
8943        if source_ids.contains(edge.from_id.as_str()) {
8944            covered.insert(edge.to_id.clone());
8945        }
8946        if source_ids.contains(edge.to_id.as_str()) {
8947            covered.insert(edge.from_id.clone());
8948        }
8949    }
8950    covered
8951}
8952
8953fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8954    for key in [
8955        "observed_at_unix",
8956        "completed_at_unix",
8957        "created_at_unix",
8958        "started_at_unix",
8959    ] {
8960        if let Some(value) = node.properties.get(key)
8961            && let Ok(epoch) = value.parse::<i64>()
8962        {
8963            return epoch.div_euclid(86_400).clamp(0, 40_000);
8964        }
8965    }
8966    0
8967}
8968
8969fn graph_db_node_kind_score(kind: &str) -> i64 {
8970    match kind {
8971        "source_handle" => 180,
8972        "worker_context" => 170,
8973        "worker_result" => 160,
8974        "semantic_concept" | "semantic_entity" => 150,
8975        "backlog" | "job_packet" => 130,
8976        "symbol" => 120,
8977        "file" => 110,
8978        "route" => 105,
8979        "session" => 90,
8980        _ => 40,
8981    }
8982}
8983
8984fn graph_db_edge_kind_score(kind: &str) -> i64 {
8985    match kind {
8986        "mentions_concept" | "mentions_entity" => 180,
8987        "semantic_relation" => 170,
8988        "mentions" => 165,
8989        "requests_context" | "scopes_context" | "scopes_source" => 155,
8990        "explains_result" => 150,
8991        "calls" => 145,
8992        "defines" | "handled_by" | "defines_route" => 130,
8993        "contains" | "targets" => 120,
8994        "records_memory_source" | "has_vector_handle" => 115,
8995        _ => 40,
8996    }
8997}
8998
8999fn graph_db_node_usefulness_score(
9000    node: &SubstrateGraphNode,
9001    depth_by_id: &BTreeMap<String, usize>,
9002    semantic_scores: &BTreeMap<String, f64>,
9003    source_covered_ids: &BTreeSet<String>,
9004    origin_ids: &[String],
9005) -> i64 {
9006    if origin_ids.iter().any(|origin| origin == &node.id) {
9007        return 1_000_000;
9008    }
9009    let semantic = semantic_scores
9010        .get(&node.id)
9011        .map(|score| (score.max(0.0) * 1_000.0) as i64)
9012        .unwrap_or(0);
9013    let depth_penalty = depth_by_id
9014        .get(&node.id)
9015        .map(|depth| (*depth as i64).saturating_mul(55))
9016        .unwrap_or(180);
9017    let source_coverage = if source_covered_ids.contains(&node.id)
9018        || node.properties.contains_key("source_ref")
9019        || node.properties.contains_key("path")
9020    {
9021        120
9022    } else {
9023        0
9024    };
9025    graph_db_node_kind_score(&node.kind)
9026        + semantic
9027        + source_coverage
9028        + graph_db_recency_score(node).min(80)
9029        - depth_penalty
9030}
9031
9032fn graph_db_edge_usefulness_score(
9033    edge: &SubstrateGraphEdge,
9034    node_score_by_id: &BTreeMap<String, i64>,
9035    depth_by_id: &BTreeMap<String, usize>,
9036) -> i64 {
9037    let endpoint_score = node_score_by_id
9038        .get(&edge.from_id)
9039        .copied()
9040        .unwrap_or_default()
9041        .max(
9042            node_score_by_id
9043                .get(&edge.to_id)
9044                .copied()
9045                .unwrap_or_default(),
9046        );
9047    let depth_penalty = depth_by_id
9048        .get(&edge.from_id)
9049        .into_iter()
9050        .chain(depth_by_id.get(&edge.to_id))
9051        .min()
9052        .map(|depth| (*depth as i64).saturating_mul(35))
9053        .unwrap_or(140);
9054    graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
9055}
9056
9057fn graph_db_push_drop(
9058    drops: &mut BTreeMap<(String, String, String), usize>,
9059    item: &str,
9060    kind: &str,
9061    reason: &str,
9062) {
9063    *drops
9064        .entry((item.to_string(), kind.to_string(), reason.to_string()))
9065        .or_default() += 1;
9066}
9067
9068fn graph_db_budget_drop_report(
9069    drops: BTreeMap<(String, String, String), usize>,
9070) -> Vec<GraphDbDroppedByBudget> {
9071    drops
9072        .into_iter()
9073        .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
9074            item,
9075            kind,
9076            reason,
9077            dropped,
9078        })
9079        .collect()
9080}
9081
9082fn graph_db_apply_output_budget(
9083    origin_ids: &[String],
9084    semantic_scores: &BTreeMap<String, f64>,
9085    nodes: Vec<SubstrateGraphNode>,
9086    edges: Vec<SubstrateGraphEdge>,
9087    limit: Option<usize>,
9088) -> GraphDbBudgetedSubgraph {
9089    graph_db_apply_output_budget_with_depths_and_cursor(
9090        origin_ids,
9091        semantic_scores,
9092        nodes,
9093        edges,
9094        limit,
9095        None,
9096        None,
9097    )
9098}
9099
9100fn graph_db_apply_output_budget_with_depths_and_cursor(
9101    origin_ids: &[String],
9102    semantic_scores: &BTreeMap<String, f64>,
9103    nodes: Vec<SubstrateGraphNode>,
9104    edges: Vec<SubstrateGraphEdge>,
9105    limit: Option<usize>,
9106    depth_overrides: Option<&BTreeMap<String, usize>>,
9107    cursor: Option<&str>,
9108) -> GraphDbBudgetedSubgraph {
9109    let max_tokens = graph_db_output_token_cap(limit);
9110    let candidate_nodes = nodes.len();
9111    let candidate_edges = edges.len();
9112    let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
9113    if let Some(depth_overrides) = depth_overrides {
9114        for (id, depth) in depth_overrides {
9115            depth_by_id
9116                .entry(id.clone())
9117                .and_modify(|current| *current = (*current).min(*depth))
9118                .or_insert(*depth);
9119        }
9120    }
9121    let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
9122    let node_score_by_id = nodes
9123        .iter()
9124        .map(|node| {
9125            (
9126                node.id.clone(),
9127                graph_db_node_usefulness_score(
9128                    node,
9129                    &depth_by_id,
9130                    semantic_scores,
9131                    &source_covered_ids,
9132                    origin_ids,
9133                ),
9134            )
9135        })
9136        .collect::<BTreeMap<_, _>>();
9137
9138    let mut node_candidates = nodes.iter().collect::<Vec<_>>();
9139    node_candidates.sort_by(|left, right| {
9140        node_score_by_id
9141            .get(&right.id)
9142            .cmp(&node_score_by_id.get(&left.id))
9143            .then_with(|| left.kind.cmp(&right.kind))
9144            .then_with(|| left.label.cmp(&right.label))
9145            .then_with(|| left.id.cmp(&right.id))
9146    });
9147
9148    let cursor_skip = if let Some(cursor) = cursor {
9149        node_candidates
9150            .iter()
9151            .position(|node| node.id == cursor)
9152            .map(|pos| pos.saturating_add(1))
9153            .unwrap_or(0)
9154    } else {
9155        0
9156    };
9157    if cursor_skip > 0 {
9158        node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
9159    }
9160
9161    let mut selected_node_ids = BTreeSet::new();
9162    let mut selected_node_counts = BTreeMap::<String, usize>::new();
9163    let mut estimated_tokens = 0usize;
9164    let mut drops = BTreeMap::<(String, String, String), usize>::new();
9165    for node in &node_candidates {
9166        let kind_count = selected_node_counts
9167            .get(&node.kind)
9168            .copied()
9169            .unwrap_or_default();
9170        if !origin_ids.iter().any(|origin| origin == &node.id)
9171            && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
9172        {
9173            graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
9174            continue;
9175        }
9176        let tokens = graph_db_estimated_tokens(node);
9177        if !origin_ids.iter().any(|origin| origin == &node.id)
9178            && estimated_tokens.saturating_add(tokens) > max_tokens
9179        {
9180            graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
9181            continue;
9182        }
9183        selected_node_ids.insert(node.id.clone());
9184        *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
9185        estimated_tokens = estimated_tokens.saturating_add(tokens);
9186    }
9187
9188    let has_remaining_candidates = node_candidates
9189        .iter()
9190        .any(|node| !selected_node_ids.contains(&node.id));
9191
9192    let mut selected_nodes = nodes
9193        .into_iter()
9194        .filter(|node| selected_node_ids.contains(&node.id))
9195        .collect::<Vec<_>>();
9196
9197    let mut edge_candidates = edges
9198        .iter()
9199        .filter(|edge| {
9200            selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
9201        })
9202        .collect::<Vec<_>>();
9203    let edge_score_by_key = edge_candidates
9204        .iter()
9205        .map(|edge| {
9206            (
9207                graph_db_edge_key(edge),
9208                graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
9209            )
9210        })
9211        .collect::<BTreeMap<_, _>>();
9212    edge_candidates.sort_by(|left, right| {
9213        edge_score_by_key
9214            .get(&graph_db_edge_key(right))
9215            .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
9216            .then_with(|| left.kind.cmp(&right.kind))
9217            .then_with(|| left.from_id.cmp(&right.from_id))
9218            .then_with(|| left.to_id.cmp(&right.to_id))
9219    });
9220
9221    let endpoint_dropped_edges = edges
9222        .iter()
9223        .filter(|edge| {
9224            !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
9225        })
9226        .count();
9227    if endpoint_dropped_edges > 0 {
9228        drops.insert(
9229            (
9230                "edge".to_string(),
9231                "*".to_string(),
9232                "endpoint_node_dropped".to_string(),
9233            ),
9234            endpoint_dropped_edges,
9235        );
9236    }
9237
9238    let mut selected_edge_ids = BTreeSet::new();
9239    let mut selected_edge_counts = BTreeMap::<String, usize>::new();
9240    for edge in edge_candidates {
9241        let kind_count = selected_edge_counts
9242            .get(&edge.kind)
9243            .copied()
9244            .unwrap_or_default();
9245        if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
9246            graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
9247            continue;
9248        }
9249        let tokens = graph_db_estimated_tokens(edge);
9250        if estimated_tokens.saturating_add(tokens) > max_tokens {
9251            graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
9252            continue;
9253        }
9254        selected_edge_ids.insert(graph_db_edge_key(edge));
9255        *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
9256        estimated_tokens = estimated_tokens.saturating_add(tokens);
9257    }
9258
9259    let selected_edges = edges
9260        .into_iter()
9261        .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
9262        .collect::<Vec<_>>();
9263    let dropped_by_budget = graph_db_budget_drop_report(drops);
9264    let truncated = has_remaining_candidates;
9265    let next_cursor = if truncated {
9266        selected_nodes.last().map(|node| node.id.clone())
9267    } else {
9268        None
9269    };
9270    let mut diagnostics = vec![
9271        "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
9272            .to_string(),
9273        format!(
9274            "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
9275            selected_nodes.len(),
9276            candidate_nodes,
9277            selected_edges.len(),
9278            candidate_edges,
9279            max_tokens
9280        ),
9281    ];
9282    if cursor.is_some() {
9283        diagnostics.push(format!(
9284            "cursor skipped {} previously returned candidate(s)",
9285            cursor_skip
9286        ));
9287    }
9288    if next_cursor.is_some() {
9289        diagnostics.push(
9290            "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
9291        );
9292    }
9293    selected_nodes.shrink_to_fit();
9294
9295    GraphDbBudgetedSubgraph {
9296        nodes: selected_nodes,
9297        edges: selected_edges,
9298        report: GraphDbOutputBudgetReport {
9299            max_tokens,
9300            estimated_tokens,
9301            selected_nodes: selected_node_ids.len(),
9302            selected_edges: selected_edge_ids.len(),
9303            candidate_nodes,
9304            candidate_edges,
9305            dropped_by_budget,
9306            diagnostics,
9307        },
9308        truncated,
9309        next_cursor,
9310    }
9311}
9312
9313fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
9314    if edge.id.is_empty() {
9315        substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
9316    } else {
9317        edge.id.clone()
9318    }
9319}
9320
9321fn graph_db_schema() -> GraphDbSchema {
9322    GraphDbSchema {
9323        contract_versions: vec![
9324            GraphDbSchemaContract {
9325                name: "graph_db_evidence",
9326                version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9327                description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
9328            },
9329            GraphDbSchemaContract {
9330                name: "worker_prompt_packet",
9331                version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
9332                description: "conflict-matrix worker prompt packet with owned scope, scheduler fields, stable graph handles, expected tests, expansion commands, token budget, semantic ranking reasons, worker feedback closure controls, and fail-closed prompt text",
9333            },
9334            GraphDbSchemaContract {
9335                name: "conflict_matrix",
9336                version: CONFLICT_MATRIX_CONTRACT_VERSION,
9337                description: "parallel-dispatch decision report keyed by graph evidence packets, scheduler block fields, hard file/symbol/test/config gates, and soft worker-feedback closure ranking",
9338            },
9339            GraphDbSchemaContract {
9340                name: "context_pack_graph_orchestration",
9341                version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
9342                description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
9343            },
9344            GraphDbSchemaContract {
9345                name: "session_review_follow_up",
9346                version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
9347                description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
9348            },
9349            GraphDbSchemaContract {
9350                name: "dispatch_trace",
9351                version: DISPATCH_TRACE_CONTRACT_VERSION,
9352                description: "operator review trace linking backlog, job packets, worker results, source handles, semantic rows, scheduler fields, evidence packet ids, worker feedback closure controls, and worker prompt packets",
9353            },
9354            GraphDbSchemaContract {
9355                name: "dependency_dag",
9356                version: DEPENDENCY_DAG_CONTRACT_VERSION,
9357                description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
9358            },
9359        ],
9360        node_fields: vec![
9361            GraphDbSchemaField {
9362                name: "id",
9363                value_type: "string",
9364                description: "Stable provider-neutral node id",
9365            },
9366            GraphDbSchemaField {
9367                name: "kind",
9368                value_type: "string",
9369                description: "Application-defined node family such as file, symbol, or backlog",
9370            },
9371            GraphDbSchemaField {
9372                name: "label",
9373                value_type: "string",
9374                description: "Human-readable label",
9375            },
9376            GraphDbSchemaField {
9377                name: "properties",
9378                value_type: "object<string,string>",
9379                description: "Adapter-specific string properties",
9380            },
9381            GraphDbSchemaField {
9382                name: "provenance",
9383                value_type: "array",
9384                description: "Source system and source reference metadata",
9385            },
9386            GraphDbSchemaField {
9387                name: "freshness",
9388                value_type: "object|null",
9389                description: "Optional content hash and observed timestamp",
9390            },
9391        ],
9392        edge_fields: vec![
9393            GraphDbSchemaField {
9394                name: "id",
9395                value_type: "string",
9396                description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
9397            },
9398            GraphDbSchemaField {
9399                name: "from_id",
9400                value_type: "string",
9401                description: "Source node id",
9402            },
9403            GraphDbSchemaField {
9404                name: "to_id",
9405                value_type: "string",
9406                description: "Target node id",
9407            },
9408            GraphDbSchemaField {
9409                name: "kind",
9410                value_type: "string",
9411                description: "Application-defined edge relation",
9412            },
9413            GraphDbSchemaField {
9414                name: "properties",
9415                value_type: "object<string,string>",
9416                description: "Adapter-specific string properties",
9417            },
9418            GraphDbSchemaField {
9419                name: "provenance",
9420                value_type: "array",
9421                description: "Source system and source reference metadata",
9422            },
9423            GraphDbSchemaField {
9424                name: "freshness",
9425                value_type: "object|null",
9426                description: "Optional content hash and observed timestamp",
9427            },
9428        ],
9429        operations: vec![
9430            GraphDbSchemaOperation {
9431                command: "refresh",
9432                description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
9433            },
9434            GraphDbSchemaOperation {
9435                command: "status",
9436                description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
9437            },
9438            GraphDbSchemaOperation {
9439                command: "doctor",
9440                description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
9441            },
9442            GraphDbSchemaOperation {
9443                command: "drift",
9444                description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
9445            },
9446            GraphDbSchemaOperation {
9447                command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
9448                description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
9449            },
9450            GraphDbSchemaOperation {
9451                command: "snapshot-export <output.db.gz> [--force]",
9452                description: "Export the current SQLite graph.db as a gzip-compressed shareable artifact only after freshness, doctor, WAL, and sidecar checks pass",
9453            },
9454            GraphDbSchemaOperation {
9455                command: "snapshot-import <artifact.db.gz> [--replace]",
9456                description: "Stage and validate a compressed SQLite graph.db artifact through doctor and freshness checks before replacing the local graph.db",
9457            },
9458            GraphDbSchemaOperation {
9459                command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
9460                description: "Benchmark experimental read-only GraphStore backend prototypes against SQLite on bounded real, optional full-project, and synthetic projections across refresh/status/path tiers/evidence/conflict-matrix/dispatch-trace and emit promotion hold/eligibility gates",
9461            },
9462            GraphDbSchemaOperation {
9463                command: "evidence <target> [--depth N] [--limit N]",
9464                description: "Return a bounded versioned graph-db handoff packet for a backlog id or job packet handle, including packet_id, projection hash, worker_context rows, source_handle rows, worker_result rows, semantic_concept/entity rows, shortest paths, replay commands, repair commands, and next commands",
9465            },
9466            GraphDbSchemaOperation {
9467                command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
9468                description: "Resolve a natural-language phrase to cached semantic concept/entity seed nodes, then return an incident/outgoing GraphStore neighborhood around those seeds for general knowledge retrieval without changing stable neighborhood pagination defaults",
9469            },
9470            GraphDbSchemaOperation {
9471                command: "dispatch-trace [target...] --path <session> [--format json|html]",
9472                description: "Export a compact graph-backed dispatch trace with evidence packet ids, worker-result feedback closure summaries, graph links, and conflict-matrix worker prompt packets",
9473            },
9474            GraphDbSchemaOperation {
9475                command: "dependency-dag [target...] --path <session>",
9476                description: "Extract a versioned agent-doc dependency DAG from backlog ids, explicit depends-on text, shared file/symbol/test/config evidence, semantic overlap, and worker-result follow-up ids",
9477            },
9478            GraphDbSchemaOperation {
9479                command: "schema",
9480                description: "Return record and operation schemas",
9481            },
9482            GraphDbSchemaOperation {
9483                command: "node <id>",
9484                description: "Return one node by stable id",
9485            },
9486            GraphDbSchemaOperation {
9487                command: "edge <id>",
9488                description: "Return one edge by stable edge id",
9489            },
9490            GraphDbSchemaOperation {
9491                command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9492                description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
9493            },
9494            GraphDbSchemaOperation {
9495                command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9496                description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
9497            },
9498            GraphDbSchemaOperation {
9499                command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
9500                description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
9501            },
9502            GraphDbSchemaOperation {
9503                command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
9504                description: "Return a directed outgoing subgraph around a node using batched SQLite recursive traversal plus pushed filters/paging when available; JSON also includes additive ranked_neighbors while default nodes remain stable-id ordered",
9505            },
9506            GraphDbSchemaOperation {
9507                command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
9508                description: "Return the shortest directed path by node id, optionally bounded by hop count",
9509            },
9510        ],
9511    }
9512}
9513
9514pub(crate) fn sqlite_graph_freshness(
9515    store: &SqliteGraphStore,
9516    scope: &str,
9517) -> Result<GraphDbFreshnessReport> {
9518    let version = store.projection_version(scope)?;
9519    let Some(version) = version else {
9520        return Ok(GraphDbFreshnessReport {
9521            status: "missing".to_string(),
9522            fail_closed: true,
9523            projection_version: None,
9524            content_hash: None,
9525            source_watermark: None,
9526            diagnostics: vec![
9527                "graph projection metadata is missing; rebuild the graph before trusting reads"
9528                    .to_string(),
9529            ],
9530        });
9531    };
9532    let mut diagnostics = Vec::new();
9533    let fail_closed =
9534        version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
9535    if version.projection_version != GRAPH_PROJECTION_VERSION {
9536        diagnostics.push(format!(
9537            "projection version mismatch: expected {} got {}",
9538            GRAPH_PROJECTION_VERSION, version.projection_version
9539        ));
9540    }
9541    if version.content_hash.is_none() {
9542        diagnostics.push("projection content hash is missing".to_string());
9543    }
9544    Ok(GraphDbFreshnessReport {
9545        status: if fail_closed { "stale" } else { "current" }.to_string(),
9546        fail_closed,
9547        projection_version: Some(version.projection_version),
9548        content_hash: version.content_hash,
9549        source_watermark: version.source_watermark,
9550        diagnostics,
9551    })
9552}
9553
9554pub(crate) fn convex_graph_freshness(
9555    local: &ConvexProjectionRows,
9556    snapshot: &ConvexProjectionRows,
9557    scope: Option<&str>,
9558) -> GraphDbFreshnessReport {
9559    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
9560    GraphDbFreshnessReport {
9561        status: freshness.status,
9562        fail_closed: freshness.fail_closed,
9563        projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
9564        content_hash: freshness.snapshot_hash,
9565        source_watermark: None,
9566        diagnostics: freshness.diagnostics,
9567    }
9568}
9569
9570pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
9571    let (nodes, edges) = store.graph_counts()?;
9572    let files = store.file_count()?;
9573    Ok(GraphDbFreshnessReport {
9574        status: "current".to_string(),
9575        fail_closed: false,
9576        projection_version: Some("tokensave-readonly".to_string()),
9577        content_hash: None,
9578        source_watermark: Some(store.db_path().to_string_lossy().to_string()),
9579        diagnostics: vec![format!(
9580            "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
9581            nodes, edges, files
9582        )],
9583    })
9584}
9585
9586pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
9587    match TokensaveDb::discover(root) {
9588        Ok(Some(store)) => {
9589            report.push_check(GraphDbDoctorCheck {
9590                name: "tokensave_db_open".to_string(),
9591                status: "ok".to_string(),
9592                fail_closed: false,
9593                diagnostics: vec![format!(
9594                    "opened tokensave database at {}",
9595                    store.db_path().display()
9596                )],
9597                repair_commands: Vec::new(),
9598            });
9599            match (store.node_count(), store.edge_count(), store.file_count()) {
9600                (Ok(nodes), Ok(edges), Ok(files)) => {
9601                    report.push_check(GraphDbDoctorCheck {
9602                        name: "tokensave_counts".to_string(),
9603                        status: "ok".to_string(),
9604                        fail_closed: false,
9605                        diagnostics: vec![format!(
9606                            "tokensave contains {} node(s), {} edge(s), {} file(s)",
9607                            nodes, edges, files
9608                        )],
9609                        repair_commands: Vec::new(),
9610                    });
9611                }
9612                (nodes, edges, files) => {
9613                    report.push_check(graph_db_doctor_check(
9614                        "tokensave_counts",
9615                        vec![format!(
9616                            "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
9617                            nodes.err(),
9618                            edges.err(),
9619                            files.err()
9620                        )],
9621                        Vec::new(),
9622                    ));
9623                }
9624            }
9625        }
9626        Ok(None) => report.push_check(graph_db_doctor_check(
9627            "tokensave_db_exists",
9628            vec![format!(
9629                "tokensave database is missing at {}",
9630                root.join(".tokensave").join("tokensave.db").display()
9631            )],
9632            Vec::new(),
9633        )),
9634        Err(err) => report.push_check(graph_db_doctor_check(
9635            "tokensave_db_open",
9636            vec![err.to_string()],
9637            Vec::new(),
9638        )),
9639    }
9640}
9641
9642const GRAPH_DB_EVIDENCE_TARGET_KINDS: &[&str] = &[
9643    "backlog",
9644    "job_packet",
9645    "worker_result",
9646    "worker_context",
9647    "source_handle",
9648];
9649
9650pub(crate) fn graph_db_evidence_preferred_path(root: &Path, path_hint: &Path) -> Option<String> {
9651    hinted_markdown_file(root, path_hint).map(|path| {
9652        relativize_pathbuf(&path, root)
9653            .to_string_lossy()
9654            .replace('\\', "/")
9655    })
9656}
9657
9658fn graph_db_ambiguous_target_message(
9659    target: &str,
9660    kind: &str,
9661    candidates: &[SubstrateGraphNode],
9662) -> String {
9663    let mut by_path = BTreeMap::<String, String>::new();
9664    for candidate in candidates.iter().filter(|node| node.kind == kind) {
9665        let path = candidate
9666            .properties
9667            .get("path")
9668            .cloned()
9669            .unwrap_or_else(|| "<no path>".to_string());
9670        by_path.entry(path).or_insert_with(|| candidate.id.clone());
9671    }
9672    let examples = by_path
9673        .iter()
9674        .take(5)
9675        .map(|(path, node_id)| format!("{node_id} path={path}"))
9676        .collect::<Vec<_>>()
9677        .join(", ");
9678    format!(
9679        "graph-db evidence target {target} is ambiguous across {} {kind} node paths: {examples}; rerun with --path <agent-doc.md> or use an exact graph node id",
9680        by_path.len()
9681    )
9682}
9683
9684pub(crate) fn graph_db_resolve_evidence_target_with_path(
9685    store: &impl GraphStore,
9686    target: &str,
9687    preferred_path: Option<&str>,
9688) -> Result<Option<SubstrateGraphNode>> {
9689    if let Some(node) = store.node(target)? {
9690        return Ok(Some(node));
9691    }
9692    let candidates =
9693        store.evidence_target_candidates(target, GRAPH_DB_EVIDENCE_TARGET_KINDS, preferred_path)?;
9694    if candidates.is_empty() {
9695        return Ok(None);
9696    }
9697    if preferred_path.is_none() {
9698        let first_kind = candidates[0].kind.as_str();
9699        let distinct_paths = candidates
9700            .iter()
9701            .filter(|node| node.kind == first_kind)
9702            .map(|node| {
9703                node.properties
9704                    .get("path")
9705                    .map(String::as_str)
9706                    .unwrap_or("")
9707            })
9708            .collect::<BTreeSet<_>>();
9709        if distinct_paths.len() > 1 {
9710            bail!(
9711                "{}",
9712                graph_db_ambiguous_target_message(target, first_kind, &candidates)
9713            );
9714        }
9715    }
9716    Ok(candidates.into_iter().next())
9717}
9718
9719pub(crate) fn graph_db_resolve_evidence_target(
9720    store: &impl GraphStore,
9721    target: &str,
9722) -> Result<Option<SubstrateGraphNode>> {
9723    graph_db_resolve_evidence_target_with_path(store, target, None)
9724}
9725
9726fn graph_db_reachable_nodes_by_kind(
9727    store: &impl GraphStore,
9728    from_id: &str,
9729    kind: &str,
9730    depth: usize,
9731    limit: usize,
9732) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9733    store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9734}
9735
9736fn graph_db_evidence_completed_queue_drift_warnings(
9737    store: &impl GraphStore,
9738    target: &SubstrateGraphNode,
9739    worker_results: &[SubstrateGraphNode],
9740) -> Result<Vec<String>> {
9741    let ref_id = target.properties.get("ref_id").map(String::as_str);
9742    let has_completed_result = worker_results.iter().any(|node| {
9743        node.properties.get("status").map(String::as_str) == Some("completed")
9744            && node.properties.get("ref_id").map(String::as_str) == ref_id
9745    });
9746    if !has_completed_result {
9747        return Ok(Vec::new());
9748    }
9749    let active_jobs = store
9750        .nodes_by_kind("job_packet")?
9751        .into_iter()
9752        .filter(|node| {
9753            node.properties.get("ref_id").map(String::as_str) == ref_id
9754                && node.label.starts_with("do #")
9755        })
9756        .collect::<Vec<_>>();
9757    if active_jobs.is_empty() {
9758        return Ok(Vec::new());
9759    }
9760    let repair = match (target.properties.get("path"), ref_id) {
9761        (Some(path), Some(id)) => format!(
9762            "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9763            shell_quote(path),
9764            shell_quote(id),
9765            shell_quote(id)
9766        ),
9767        _ => {
9768            "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9769        }
9770    };
9771    Ok(vec![format!(
9772        "queue-head drift: target {} has {} active queued do packet(s) but already has a completed worker_result; {repair}; do not redispatch or reactivate the completed item",
9773        target.label,
9774        active_jobs.len()
9775    )])
9776}
9777
9778fn graph_db_evidence_next_commands(
9779    root: &Path,
9780    scope: Option<&str>,
9781    target: &SubstrateGraphNode,
9782    worker_context: &[SubstrateGraphNode],
9783    source_handles: &[SubstrateGraphNode],
9784    worker_results: &[SubstrateGraphNode],
9785    semantic_related: &[SubstrateGraphNode],
9786) -> Vec<String> {
9787    let mut commands = BTreeSet::new();
9788    if let Some(expand) = target.properties.get("expand") {
9789        commands.insert(expand.clone());
9790    }
9791    for worker in worker_context {
9792        if let Some(expand) = worker.properties.get("expand") {
9793            commands.insert(expand.clone());
9794        }
9795    }
9796    for source in source_handles {
9797        if let Some(expand) = source.properties.get("expand") {
9798            commands.insert(expand.clone());
9799        }
9800    }
9801    for result in worker_results {
9802        if let Some(expand) = result.properties.get("expand") {
9803            commands.insert(expand.clone());
9804        }
9805    }
9806    for semantic in semantic_related {
9807        if let Some(expand) = semantic.properties.get("expand") {
9808            commands.insert(expand.clone());
9809        }
9810    }
9811    commands.insert(format!(
9812        "tsift graph-db --path {}{} status --json",
9813        shell_quote(root.to_string_lossy().as_ref()),
9814        graph_db_scope_arg(scope)
9815    ));
9816    commands.insert(format!(
9817        "tsift graph-db --path {}{} doctor --json",
9818        shell_quote(root.to_string_lossy().as_ref()),
9819        graph_db_scope_arg(scope)
9820    ));
9821    commands.into_iter().collect()
9822}
9823
9824fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9825    vec![
9826        format!(
9827            "tsift graph-db --path {}{} refresh --json",
9828            shell_quote(root.to_string_lossy().as_ref()),
9829            graph_db_scope_arg(scope)
9830        ),
9831        format!(
9832            "tsift graph-db --path {}{} doctor --json",
9833            shell_quote(root.to_string_lossy().as_ref()),
9834            graph_db_scope_arg(scope)
9835        ),
9836    ]
9837}
9838
9839fn graph_db_evidence_replay_commands(
9840    root: &Path,
9841    scope: Option<&str>,
9842    target: &str,
9843    depth: usize,
9844    limit: usize,
9845) -> Vec<String> {
9846    vec![
9847        format!(
9848            "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9849            shell_quote(root.to_string_lossy().as_ref()),
9850            graph_db_scope_arg(scope),
9851            shell_quote(target),
9852            depth,
9853            limit
9854        ),
9855        format!(
9856            "tsift conflict-matrix --path {} {} --json",
9857            shell_quote(root.to_string_lossy().as_ref()),
9858            shell_quote(target)
9859        ),
9860    ]
9861}
9862
9863fn graph_db_evidence_packet_id(
9864    target: &str,
9865    target_node: &SubstrateGraphNode,
9866    freshness: &GraphDbFreshnessReport,
9867) -> String {
9868    stable_handle(
9869        "gevd",
9870        &format!(
9871            "{}:{}:{}:{}",
9872            GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9873            target,
9874            target_node.id,
9875            freshness.content_hash.as_deref().unwrap_or("no-hash")
9876        ),
9877    )
9878}
9879
9880pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9881    input: GraphDbEvidenceInput<'_, S>,
9882) -> Result<GraphDbEvidenceReport> {
9883    let GraphDbEvidenceInput {
9884        root,
9885        scope,
9886        backend,
9887        target,
9888        preferred_path,
9889        depth,
9890        limit,
9891        cursor,
9892        store,
9893        freshness,
9894        mut warnings,
9895    } = input;
9896    let repair_commands = graph_db_repair_commands(root, scope);
9897    if freshness.fail_closed {
9898        bail!(
9899            "graph database evidence failed closed for {} backend: {}; repair: {}",
9900            backend,
9901            freshness.diagnostics.join("; "),
9902            repair_commands.join("; ")
9903        );
9904    }
9905    let semantic_readiness =
9906        graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9907    if semantic_readiness.fail_closed {
9908        warnings.push(format!(
9909            "graph evidence semantic readiness blocked: {} — {}",
9910            semantic_readiness.reason,
9911            semantic_readiness.diagnostics.join("; ")
9912        ));
9913        warnings.push(format!(
9914            "repair: {}",
9915            semantic_readiness.next_commands.join("; then ")
9916        ));
9917    }
9918    let target_node = graph_db_resolve_evidence_target_with_path(store, target, preferred_path)?
9919        .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9920    let max_rows = if limit == 0 { usize::MAX } else { limit };
9921    let mut reachable = store.reachable_nodes_by_kinds(
9922        &target_node.id,
9923        &[
9924            "worker_context",
9925            "source_handle",
9926            "worker_result",
9927            "semantic_concept",
9928            "semantic_entity",
9929        ],
9930        depth,
9931        max_rows,
9932    )?;
9933    let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9934    let source_paths = reachable.remove("source_handle").unwrap_or_default();
9935    let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9936    let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9937    semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9938    semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9939        left_path
9940            .hops
9941            .cmp(&right_path.hops)
9942            .then(left_node.kind.cmp(&right_node.kind))
9943            .then(left_node.label.cmp(&right_node.label))
9944            .then(left_node.id.cmp(&right_node.id))
9945    });
9946    if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9947        semantic_paths.truncate(max_rows);
9948    }
9949
9950    let evidence_nodes = worker_paths
9951        .iter()
9952        .chain(source_paths.iter())
9953        .chain(worker_result_paths.iter())
9954        .chain(semantic_paths.iter())
9955        .map(|(node, _)| node.clone())
9956        .collect::<Vec<_>>();
9957    let evidence_depth_by_id = worker_paths
9958        .iter()
9959        .chain(source_paths.iter())
9960        .chain(worker_result_paths.iter())
9961        .chain(semantic_paths.iter())
9962        .map(|(node, path)| (node.id.clone(), path.hops))
9963        .collect::<BTreeMap<_, _>>();
9964    let target_query = graph_db_node_search_text(&target_node);
9965    let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9966    let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9967        std::slice::from_ref(&target_node.id),
9968        &semantic_scores,
9969        evidence_nodes,
9970        Vec::new(),
9971        Some(limit),
9972        Some(&evidence_depth_by_id),
9973        cursor,
9974    );
9975    let output_budget = budgeted.report;
9976    let truncated = budgeted.truncated;
9977    let next_cursor = budgeted.next_cursor;
9978    let retained_evidence_ids = budgeted
9979        .nodes
9980        .iter()
9981        .map(|node| node.id.as_str())
9982        .collect::<BTreeSet<_>>();
9983    let worker_context = worker_paths
9984        .iter()
9985        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9986        .map(|(node, _)| node.clone())
9987        .collect::<Vec<_>>();
9988    let source_handles = source_paths
9989        .iter()
9990        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9991        .map(|(node, _)| node.clone())
9992        .collect::<Vec<_>>();
9993    let worker_results = worker_result_paths
9994        .iter()
9995        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9996        .map(|(node, _)| node.clone())
9997        .collect::<Vec<_>>();
9998    let semantic_related = semantic_paths
9999        .iter()
10000        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
10001        .map(|(node, _)| node.clone())
10002        .collect::<Vec<_>>();
10003    warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
10004        store,
10005        &target_node,
10006        &worker_results,
10007    )?);
10008    if worker_context.is_empty()
10009        && source_handles.is_empty()
10010        && worker_results.is_empty()
10011        && semantic_related.is_empty()
10012    {
10013        warnings.push(format!(
10014            "graph-db evidence target {} resolved to a {} node but has no projection-linked context rows; add source/file tokens to the backlog text or rerun graph-db refresh after the session document is indexed",
10015            target, target_node.kind
10016        ));
10017    }
10018    let shortest_paths = worker_paths
10019        .iter()
10020        .chain(source_paths.iter())
10021        .chain(worker_result_paths.iter())
10022        .chain(semantic_paths.iter())
10023        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
10024        .map(|(node, path)| GraphDbEvidencePath {
10025            to: node.id.clone(),
10026            kind: node.kind.clone(),
10027            label: node.label.clone(),
10028            path: Some(path.clone()),
10029            expand: node.properties.get("expand").cloned(),
10030        })
10031        .collect::<Vec<_>>();
10032    let next_commands = graph_db_evidence_next_commands(
10033        root,
10034        scope,
10035        &target_node,
10036        &worker_context,
10037        &source_handles,
10038        &worker_results,
10039        &semantic_related,
10040    );
10041    let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
10042    let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
10043    let projection_hash = freshness.content_hash.clone();
10044
10045    Ok(GraphDbEvidenceReport {
10046        root: root.to_string_lossy().to_string(),
10047        scope: scope.map(str::to_string),
10048        backend: backend.to_string(),
10049        contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
10050        target: target.to_string(),
10051        packet_id,
10052        projection_hash,
10053        freshness,
10054        target_node: target_node.into(),
10055        worker_context: worker_context.into_iter().map(Into::into).collect(),
10056        source_handles: source_handles.into_iter().map(Into::into).collect(),
10057        worker_results: worker_results.into_iter().map(Into::into).collect(),
10058        semantic_related: semantic_related.into_iter().map(Into::into).collect(),
10059        shortest_paths,
10060        output_budget: Some(output_budget),
10061        truncated,
10062        next_cursor,
10063        next_commands,
10064        replay_commands,
10065        repair_commands,
10066        fixture_coverage: GraphDbFixtureCoverage {
10067            test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
10068                .to_string(),
10069            fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
10070            assertions: vec![
10071                "backlog id and job packet handle resolve to graph nodes".to_string(),
10072                "worker_context rows are reachable from queued work".to_string(),
10073                "source_handle rows are reachable through bounded shortest paths".to_string(),
10074                "worker_result rows are reachable from completed or blocked work".to_string(),
10075            ],
10076        },
10077        warnings,
10078    })
10079}
10080
10081fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
10082    println!(
10083        "graph-db evidence backend: {} target: {} [{}] packet:{}",
10084        report.backend, report.target_node.id, report.target_node.kind, report.packet_id
10085    );
10086    let page_info = if report.truncated {
10087        let cursor = report.next_cursor.as_deref().unwrap_or("?");
10088        format!(" (truncated, next_cursor: {cursor})")
10089    } else {
10090        String::new()
10091    };
10092    println!(
10093        "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
10094        report.worker_context.len(),
10095        report.source_handles.len(),
10096        report.worker_results.len(),
10097        report.semantic_related.len(),
10098        report.shortest_paths.len()
10099    );
10100    for path in &report.shortest_paths {
10101        if let Some(graph_path) = &path.path {
10102            println!(
10103                "path: {} hop(s) {}",
10104                graph_path.hops,
10105                graph_path.nodes.join(" -> ")
10106            );
10107        }
10108    }
10109    for command in &report.next_commands {
10110        println!("next: {command}");
10111    }
10112    for warning in &report.warnings {
10113        println!("warning: {warning}");
10114    }
10115}
10116
10117pub(crate) fn print_graph_db_evidence_report(
10118    report: &GraphDbEvidenceReport,
10119    format: OutputFormat,
10120) -> Result<()> {
10121    if format.json_output {
10122        let page_info = if report.truncated {
10123            let cursor = report.next_cursor.as_deref().unwrap_or("?");
10124            format!(" (truncated, next_cursor: {cursor})")
10125        } else {
10126            String::new()
10127        };
10128        print_json_or_envelope(
10129            report,
10130            &format,
10131            "graph-db",
10132            "evidence",
10133            ToolEnvelopeSummary {
10134                text: format!(
10135                    "Graph DB evidence for {} returned {} worker context row(s), {} source handle(s), {} worker result row(s), {} semantic row(s), and {} shortest path(s){page_info}",
10136                    report.target,
10137                    report.worker_context.len(),
10138                    report.source_handles.len(),
10139                    report.worker_results.len(),
10140                    report.semantic_related.len(),
10141                    report.shortest_paths.len()
10142                ),
10143                metrics: vec![
10144                    envelope_metric("backend", &report.backend),
10145                    envelope_metric("worker_context", report.worker_context.len()),
10146                    envelope_metric("source_handles", report.source_handles.len()),
10147                    envelope_metric("worker_results", report.worker_results.len()),
10148                    envelope_metric("semantic_related", report.semantic_related.len()),
10149                    envelope_metric("paths", report.shortest_paths.len()),
10150                ],
10151            },
10152            report.truncated,
10153            report.next_commands.clone(),
10154        )
10155    } else {
10156        print_graph_db_evidence_human(report);
10157        Ok(())
10158    }
10159}
10160
10161pub(crate) fn graph_db_report_from_store(
10162    root: &Path,
10163    scope: Option<&str>,
10164    backend: &str,
10165    query: GraphDbQuery,
10166    store: &impl GraphStore,
10167    freshness: GraphDbFreshnessReport,
10168    warnings: Vec<String>,
10169) -> Result<GraphDbReport> {
10170    if freshness.fail_closed {
10171        bail!(
10172            "graph database read failed closed for {} backend: {}",
10173            backend,
10174            freshness.diagnostics.join("; ")
10175        );
10176    }
10177    let mut report = GraphDbReport {
10178        root: root.to_string_lossy().to_string(),
10179        scope: scope.map(str::to_string),
10180        backend: backend.to_string(),
10181        query: format!("{query:?}"),
10182        freshness,
10183        readiness: None,
10184        schema: None,
10185        node: None,
10186        edge: None,
10187        nodes: Vec::new(),
10188        edges: Vec::new(),
10189        ranked_neighbors: Vec::new(),
10190        semantic_related: Vec::new(),
10191        neighborhood_ranking_gate: None,
10192        ranked_neighborhood_comparison: None,
10193        knowledge_retrieval: None,
10194        output_budget: None,
10195        path: None,
10196        page: None,
10197        warnings,
10198    };
10199
10200    match query {
10201        GraphDbQuery::Refresh => {
10202            bail!("graph-db refresh must be handled by the refresh command path");
10203        }
10204        GraphDbQuery::Status => {
10205            bail!("graph-db status must be handled by the status command path");
10206        }
10207        GraphDbQuery::Doctor => {
10208            bail!("graph-db doctor must be handled by the doctor command path");
10209        }
10210        GraphDbQuery::Drift => {
10211            bail!("graph-db drift must be handled by the drift command path");
10212        }
10213        GraphDbQuery::Compact { .. } => {
10214            bail!("graph-db compact must be handled by the compact command path");
10215        }
10216        GraphDbQuery::SnapshotExport { .. } => {
10217            bail!("graph-db snapshot-export must be handled by the snapshot command path");
10218        }
10219        GraphDbQuery::SnapshotImport { .. } => {
10220            bail!("graph-db snapshot-import must be handled by the snapshot command path");
10221        }
10222        GraphDbQuery::BackendEval { .. } => {
10223            bail!("graph-db backend-eval must be handled by the benchmark command path");
10224        }
10225        GraphDbQuery::Evidence { .. } => {
10226            bail!("graph-db evidence must be handled by the evidence command path");
10227        }
10228        GraphDbQuery::Related {
10229            query,
10230            kind,
10231            depth,
10232            seed_limit,
10233            limit,
10234        } => {
10235            let semantic =
10236                semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
10237            let SemanticRelatedReport {
10238                items,
10239                warnings: semantic_warnings,
10240                ..
10241            } = semantic;
10242            let readiness = graph_db_semantic_readiness(
10243                root,
10244                scope,
10245                (!items.is_empty()).then_some(items.len()),
10246            );
10247            report.warnings.extend(semantic_warnings);
10248            let seed_ids = items
10249                .iter()
10250                .map(|item| item.handle.clone())
10251                .collect::<Vec<_>>();
10252            let semantic_scores = items
10253                .iter()
10254                .map(|item| (item.handle.clone(), item.score))
10255                .collect::<BTreeMap<_, _>>();
10256            let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
10257            let seed_count = seed_ids.len();
10258            let mut diagnostics = subgraph.diagnostics;
10259            let budgeted = graph_db_apply_output_budget(
10260                &seed_ids,
10261                &semantic_scores,
10262                subgraph.nodes,
10263                subgraph.edges,
10264                Some(limit),
10265            );
10266            let budget_report = budgeted.report;
10267            let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
10268            diagnostics.extend(budget_report.diagnostics.clone());
10269            diagnostics.extend(readiness.diagnostics.clone());
10270
10271            report.readiness = Some(readiness);
10272            report.semantic_related = items;
10273            if let Some(seed_id) = seed_ids.first() {
10274                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
10275                report.ranked_neighbors = graph_db_ranked_neighbors(
10276                    seed_id,
10277                    &budgeted.nodes,
10278                    &budgeted.edges,
10279                    ranked_neighbor_cap,
10280                );
10281                report.neighborhood_ranking_gate =
10282                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10283            }
10284            report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10285            report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10286            report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
10287                mode: "semantic_seeded_neighborhood".to_string(),
10288                query,
10289                seed_kind: semantic_related_kind_name(kind).to_string(),
10290                seed_limit,
10291                seed_count,
10292                depth,
10293                limit,
10294                node_count: report.nodes.len(),
10295                edge_count: report.edges.len(),
10296                truncated: subgraph.truncated || dropped_by_budget,
10297                traversal: "incident_plus_outgoing_edges".to_string(),
10298                freshness_boundary:
10299                    "semantic rows must come from refreshed summary or tsift-memory graph records"
10300                        .to_string(),
10301                privacy_boundary:
10302                    "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
10303                        .to_string(),
10304                diagnostics,
10305            });
10306            report.output_budget = Some(budget_report);
10307        }
10308        GraphDbQuery::Schema => {
10309            report.schema = Some(graph_db_schema());
10310        }
10311        GraphDbQuery::Node { id } => {
10312            report.node = store.node(&id)?.map(Into::into);
10313        }
10314        GraphDbQuery::Edge { id } => {
10315            report.edge = store.edge(&id)?.map(Into::into);
10316        }
10317        GraphDbQuery::Edges {
10318            edge_kind,
10319            cursor,
10320            limit,
10321            property_filters,
10322        } => {
10323            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10324            let paged = store.paged_edges(
10325                edge_kind.as_deref(),
10326                graph_db_query_options_for_store(&options),
10327            )?;
10328            report.edges = paged.edges.into_iter().map(Into::into).collect();
10329            report.page = Some(graph_db_page_report_from_store(
10330                paged.page,
10331                options.property_filters,
10332            ));
10333        }
10334        GraphDbQuery::Incident {
10335            id,
10336            edge_kind,
10337            cursor,
10338            limit,
10339            property_filters,
10340        } => {
10341            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10342            let paged = store.paged_incident_edges(
10343                &id,
10344                edge_kind.as_deref(),
10345                graph_db_query_options_for_store(&options),
10346            )?;
10347            report.edges = paged.edges.into_iter().map(Into::into).collect();
10348            report.page = Some(graph_db_page_report_from_store(
10349                paged.page,
10350                options.property_filters,
10351            ));
10352        }
10353        GraphDbQuery::Kind {
10354            kind,
10355            cursor,
10356            limit,
10357            property_filters,
10358        } => {
10359            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10360            let paged =
10361                store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
10362            report.nodes = paged.nodes.into_iter().map(Into::into).collect();
10363            report.edges = paged.edges.into_iter().map(Into::into).collect();
10364            report.page = Some(graph_db_page_report_from_store(
10365                paged.page,
10366                options.property_filters,
10367            ));
10368        }
10369        GraphDbQuery::Neighborhood {
10370            id,
10371            depth,
10372            edge_kind,
10373            cursor,
10374            limit,
10375            property_filters,
10376        } => {
10377            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10378            if let Some(paged) = store.paged_neighborhood(
10379                &id,
10380                depth,
10381                edge_kind.as_deref(),
10382                graph_db_query_options_for_store(&options),
10383            )? {
10384                let budgeted = graph_db_apply_output_budget(
10385                    std::slice::from_ref(&id),
10386                    &BTreeMap::new(),
10387                    paged.nodes,
10388                    paged.edges,
10389                    options.limit,
10390                );
10391                let budget_report = budgeted.report;
10392                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
10393                let ranked_neighbors = graph_db_ranked_neighbors(
10394                    &id,
10395                    &budgeted.nodes,
10396                    &budgeted.edges,
10397                    ranked_neighbor_cap,
10398                );
10399                let comparison = graph_db_ranked_neighborhood_comparison(
10400                    &id,
10401                    depth,
10402                    edge_kind.as_deref(),
10403                    options.limit,
10404                    &budgeted.nodes,
10405                    &budgeted.edges,
10406                    store,
10407                )?;
10408                report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10409                report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10410                report.ranked_neighbors = ranked_neighbors;
10411                report.neighborhood_ranking_gate =
10412                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10413                let mut page =
10414                    graph_db_page_report_from_store(paged.page, options.property_filters);
10415                page.returned_nodes = report.nodes.len();
10416                page.returned_edges = report.edges.len();
10417                page.truncated |= !budget_report.dropped_by_budget.is_empty();
10418                page.diagnostics.extend(budget_report.diagnostics.clone());
10419                report.page = Some(page);
10420                report.output_budget = Some(budget_report);
10421                if let Some(comparison) = comparison {
10422                    report.ranked_neighborhood_comparison = Some(comparison);
10423                }
10424            }
10425        }
10426        GraphDbQuery::Path {
10427            from,
10428            to,
10429            edge_kind,
10430            max_hops,
10431        } => {
10432            report.path =
10433                store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
10434            if let Some(max_hops) = max_hops
10435                && report.path.is_none()
10436            {
10437                report.warnings.push(format!(
10438                    "no directed path found within --max-hops {}",
10439                    max_hops
10440                ));
10441            }
10442        }
10443        GraphDbQuery::Map { .. } => {
10444            bail!("graph-db map must be handled by the map command path");
10445        }
10446    }
10447    Ok(report)
10448}
10449
10450pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
10451    if compact {
10452        println!(
10453            "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
10454            report.backend,
10455            report.query,
10456            report.nodes.len() + usize::from(report.node.is_some()),
10457            report.edges.len() + usize::from(report.edge.is_some()),
10458            report.freshness.status
10459        );
10460        return;
10461    }
10462    println!("graph-db backend: {}", report.backend);
10463    println!("freshness: {}", report.freshness.status);
10464    if let Some(readiness) = &report.readiness {
10465        println!(
10466            "readiness: {} reason: {} fail_closed: {}",
10467            readiness.status, readiness.reason, readiness.fail_closed
10468        );
10469        for diagnostic in &readiness.diagnostics {
10470            println!("readiness diagnostic: {diagnostic}");
10471        }
10472        for command in &readiness.next_commands {
10473            println!("readiness next: {command}");
10474        }
10475    }
10476    if let Some(schema) = &report.schema {
10477        println!(
10478            "schema: {} node fields, {} edge fields, {} operations",
10479            schema.node_fields.len(),
10480            schema.edge_fields.len(),
10481            schema.operations.len()
10482        );
10483    }
10484    if let Some(node) = &report.node {
10485        println!("node: {} [{}] {}", node.id, node.kind, node.label);
10486    }
10487    if let Some(edge) = &report.edge {
10488        let edge_full: SubstrateGraphEdge = edge.into();
10489        println!(
10490            "edge: {} {} -{}-> {}",
10491            graph_db_edge_key(&edge_full),
10492            edge.from_id,
10493            edge.kind,
10494            edge.to_id
10495        );
10496    }
10497    if let Some(knowledge) = &report.knowledge_retrieval {
10498        println!(
10499            "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
10500            knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
10501        );
10502    }
10503    for item in &report.semantic_related {
10504        println!(
10505            "semantic_seed: {:.3} [{}] {} ({})",
10506            item.score, item.kind, item.label, item.handle
10507        );
10508    }
10509    for node in &report.nodes {
10510        println!("node: {} [{}] {}", node.id, node.kind, node.label);
10511    }
10512    for edge in &report.edges {
10513        let edge_full: SubstrateGraphEdge = edge.into();
10514        println!(
10515            "edge: {} {} -{}-> {}",
10516            graph_db_edge_key(&edge_full),
10517            edge.from_id,
10518            edge.kind,
10519            edge.to_id
10520        );
10521    }
10522    for neighbor in &report.ranked_neighbors {
10523        println!(
10524            "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
10525            neighbor.rank,
10526            neighbor.score,
10527            neighbor
10528                .depth
10529                .map(|depth| depth.to_string())
10530                .unwrap_or_else(|| "unknown".to_string()),
10531            neighbor.node_id,
10532            neighbor.kind,
10533            neighbor.label
10534        );
10535    }
10536    if let Some(gate) = &report.neighborhood_ranking_gate {
10537        println!(
10538            "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
10539            gate.status, gate.default_order, gate.ranked_output_default
10540        );
10541    }
10542    if let Some(path) = &report.path {
10543        println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
10544    }
10545    if let Some(page) = &report.page {
10546        if let Some(next_cursor) = &page.next_cursor {
10547            println!("next_cursor: {next_cursor}");
10548        }
10549        for diagnostic in &page.diagnostics {
10550            println!("page: {diagnostic}");
10551        }
10552    }
10553    for warning in &report.warnings {
10554        println!("warning: {warning}");
10555    }
10556}
10557
10558pub(crate) fn graph_db_backend_eval_phase_timing(
10559    name: &str,
10560    duration_micros: u128,
10561    detail: &str,
10562) -> GraphDbBackendEvalPhaseTiming {
10563    GraphDbBackendEvalPhaseTiming {
10564        name: name.to_string(),
10565        duration_micros,
10566        detail: detail.to_string(),
10567    }
10568}
10569
10570pub(crate) fn graph_db_backend_eval_timed_phase<T>(
10571    phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
10572    name: &str,
10573    detail: &str,
10574    run: impl FnOnce() -> Result<T>,
10575) -> Result<T> {
10576    let started = Instant::now();
10577    let result = run();
10578    phases.push(graph_db_backend_eval_phase_timing(
10579        name,
10580        started.elapsed().as_micros(),
10581        detail,
10582    ));
10583    result
10584}
10585
10586pub(crate) fn graph_db_backend_eval_refresh_total_micros(
10587    phases: &[GraphDbBackendEvalPhaseTiming],
10588) -> u128 {
10589    phases
10590        .iter()
10591        .filter(|phase| phase.name != "conflict_matrix_preparation")
10592        .map(|phase| phase.duration_micros)
10593        .sum()
10594}
10595
10596pub(crate) fn graph_db_backend_eval_cached_refresh(
10597    root: &Path,
10598    scope: Option<&str>,
10599    source_watermark: Option<&str>,
10600) -> Result<
10601    Option<(
10602        TraversalGraphBuild,
10603        SqliteProjectionRefresh,
10604        Vec<GraphDbBackendEvalPhaseTiming>,
10605    )>,
10606> {
10607    let Some(source_watermark) = source_watermark else {
10608        return Ok(None);
10609    };
10610    let graph_db = graph_substrate_db_path(root, scope);
10611    if !graph_db.exists() {
10612        return Ok(None);
10613    }
10614
10615    let started = Instant::now();
10616    let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
10617        Ok(store) => store,
10618        Err(_) => return Ok(None),
10619    };
10620    if store.has_user_triggers().unwrap_or(true) {
10621        return Ok(None);
10622    }
10623    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
10624    if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
10625        return Ok(None);
10626    }
10627
10628    let phases = vec![
10629        graph_db_backend_eval_phase_timing(
10630            "source_graph_build",
10631            started.elapsed().as_micros(),
10632            "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10633        ),
10634        graph_db_backend_eval_phase_timing(
10635            "projection_rows",
10636            0,
10637            "reused cached provider-neutral projection rows from graph.db",
10638        ),
10639        graph_db_backend_eval_phase_timing(
10640            "sqlite_open",
10641            0,
10642            "reused existing graph.db projection without opening a write transaction",
10643        ),
10644    ];
10645    let refresh = SqliteProjectionRefresh {
10646        scope: scope.unwrap_or("root").to_string(),
10647        projection_version: freshness
10648            .projection_version
10649            .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
10650        source_watermark: Some(source_watermark.to_string()),
10651        tombstoned_nodes: Vec::new(),
10652        tombstoned_edges: Vec::new(),
10653        upserted_nodes: 0,
10654        upserted_edges: 0,
10655        unchanged_nodes: 0,
10656        unchanged_edges: 0,
10657        upserted_properties: 0,
10658        unchanged_properties: 0,
10659        deleted_properties: 0,
10660        deleted_nodes: 0,
10661        deleted_edges: 0,
10662        pruned_tombstones: 0,
10663        file_size_bytes_before: None,
10664        file_size_bytes_after: None,
10665        phase_timings: Vec::new(),
10666    };
10667    Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10668}
10669
10670pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10671    phases: &[GraphDbBackendEvalPhaseTiming],
10672) -> bool {
10673    phases.iter().any(|phase| {
10674        phase.name == "source_graph_build"
10675            && phase.detail.contains("reused current graph.db projection")
10676    })
10677}
10678
10679pub(crate) fn graph_db_backend_eval_update_source_watermark(
10680    root: &Path,
10681    path_hint: &Path,
10682    scope: Option<&str>,
10683) -> Result<()> {
10684    let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10685        return Ok(());
10686    };
10687    let graph_db = graph_substrate_db_path(root, scope);
10688    let mut store = SqliteGraphStore::open(&graph_db)?;
10689    store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10690    Ok(())
10691}
10692
10693pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10694    root: &Path,
10695    path_hint: &Path,
10696    scope: Option<&str>,
10697) -> Result<(
10698    TraversalGraphBuild,
10699    SqliteProjectionRefresh,
10700    Vec<GraphDbBackendEvalPhaseTiming>,
10701)> {
10702    let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10703    if let Some(cached) =
10704        graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10705    {
10706        return Ok(cached);
10707    }
10708
10709    let mut phases = Vec::new();
10710    let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10711        "bounded session projection: index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads; skips global call-edge materialization because full-projection is the complete-call-graph regression guard"
10712    } else {
10713        "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10714    };
10715    let source_graph = graph_db_backend_eval_timed_phase(
10716        &mut phases,
10717        "source_graph_build",
10718        source_graph_detail,
10719        || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10720    )?;
10721    let projection = graph_db_backend_eval_timed_phase(
10722        &mut phases,
10723        "projection_rows",
10724        "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10725        || traversal_projection_from_graph(root, scope, &source_graph),
10726    )?;
10727    let graph_db = graph_substrate_db_path(root, scope);
10728    let mut store = graph_db_backend_eval_timed_phase(
10729        &mut phases,
10730        "sqlite_open",
10731        "open the local SQLite graph.db with WAL and busy-timeout settings",
10732        || SqliteGraphStore::open(&graph_db),
10733    )?;
10734    let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10735        .ok()
10736        .flatten();
10737    let refresh = store.replace_projection_with_version(
10738        scope.unwrap_or("root"),
10739        &projection,
10740        Some(GRAPH_PROJECTION_VERSION),
10741        refreshed_source_watermark
10742            .or(source_watermark)
10743            .or_else(|| graph_projection_content_hash(&projection)),
10744    )?;
10745    phases.extend(
10746        refresh
10747            .phase_timings
10748            .iter()
10749            .map(|phase| GraphDbBackendEvalPhaseTiming {
10750                name: phase.name.clone(),
10751                duration_micros: phase.duration_micros,
10752                detail: phase.detail.clone(),
10753            }),
10754    );
10755    Ok((source_graph, refresh, phases))
10756}
10757
10758fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10759    root.join(".tsift/backend-eval-cache")
10760}
10761
10762fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10763    graph_db_backend_eval_disk_cache_dir(root)
10764        .join(kind)
10765        .join(format!("{key}.json.gz"))
10766}
10767
10768fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10769    graph_db_backend_eval_disk_cache_dir(root)
10770        .join(kind)
10771        .join(format!("{key}.json"))
10772}
10773
10774#[derive(Default, Clone)]
10775struct GraphDbBackendEvalDiskCacheReadProfile {
10776    file_read_micros: u128,
10777    gzip_decode_micros: u128,
10778    serde_decode_micros: u128,
10779    legacy: bool,
10780}
10781
10782fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10783    root: &Path,
10784    kind: &str,
10785    key: &str,
10786) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10787    let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10788    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10789    let read_started = Instant::now();
10790    let read_result = fs::read(&path);
10791    profile.file_read_micros = read_started.elapsed().as_micros();
10792    if let Ok(bytes) = read_result {
10793        let decode_started = Instant::now();
10794        let mut decoder = GzDecoder::new(bytes.as_slice());
10795        let mut decoded = Vec::new();
10796        let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10797        profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10798        if decode_ok {
10799            let serde_started = Instant::now();
10800            let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10801            profile.serde_decode_micros = serde_started.elapsed().as_micros();
10802            if let Some(value) = parsed {
10803                return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10804            }
10805        }
10806    }
10807
10808    let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10809    let legacy_started = Instant::now();
10810    let bytes = fs::read(legacy_path).ok()?;
10811    profile.file_read_micros = profile
10812        .file_read_micros
10813        .saturating_add(legacy_started.elapsed().as_micros());
10814    let serde_started = Instant::now();
10815    let value = serde_json::from_slice(&bytes).ok()?;
10816    profile.serde_decode_micros = profile
10817        .serde_decode_micros
10818        .saturating_add(serde_started.elapsed().as_micros());
10819    profile.legacy = true;
10820    Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10821}
10822
10823#[derive(Default, Clone)]
10824struct GraphDbBackendEvalDiskCacheWriteProfile {
10825    serde_encode_micros: u128,
10826    gzip_encode_micros: u128,
10827    file_write_micros: u128,
10828}
10829
10830fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10831    root: &Path,
10832    kind: &str,
10833    key: &str,
10834    value: &T,
10835) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10836    let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10837    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10838    let parent = path.parent()?;
10839    if fs::create_dir_all(parent).is_err() {
10840        return None;
10841    }
10842    let serde_started = Instant::now();
10843    let bytes = serde_json::to_vec(value).ok()?;
10844    profile.serde_encode_micros = serde_started.elapsed().as_micros();
10845    let gzip_started = Instant::now();
10846    let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10847    if encoder.write_all(&bytes).is_err() {
10848        return None;
10849    }
10850    let encoded = encoder.finish().ok()?;
10851    profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10852    let write_started = Instant::now();
10853    if fs::write(&path, &encoded).is_err() {
10854        return None;
10855    }
10856    profile.file_write_micros = write_started.elapsed().as_micros();
10857    Some((encoded.len() as u64, bytes.len() as u64, profile))
10858}
10859
10860fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10861    let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10862    let Ok(entries) = fs::read_dir(dir) else {
10863        return (0, 0);
10864    };
10865    let keep_name = format!("{keep_key}.json.gz");
10866    let mut pruned_files = 0usize;
10867    let mut pruned_bytes = 0u64;
10868    for entry in entries.flatten() {
10869        let path = entry.path();
10870        if !path.is_file() {
10871            continue;
10872        }
10873        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10874            continue;
10875        };
10876        if name == keep_name {
10877            continue;
10878        }
10879        let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10880        if !is_backend_eval_cache {
10881            continue;
10882        }
10883        let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10884        if fs::remove_file(&path).is_ok() {
10885            pruned_files += 1;
10886            pruned_bytes += bytes;
10887        }
10888    }
10889    (pruned_files, pruned_bytes)
10890}
10891
10892fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10893    root: &Path,
10894    source_root: &Path,
10895) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10896    let mut rows = Vec::new();
10897    let mut entries = walk::walk_files(source_root)?;
10898    entries.sort_by(|left, right| left.path.cmp(&right.path));
10899    for entry in entries {
10900        if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10901            continue;
10902        }
10903        if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10904            continue;
10905        }
10906        let bytes = fs::read(&entry.path)
10907            .with_context(|| format!("reading source input {}", entry.path.display()))?;
10908        rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10909            path: traversal_watermark_path(root, &entry.path),
10910            bytes: bytes.len() as u64,
10911            content_hash: content_hash(&bytes)?,
10912        });
10913    }
10914    Ok(rows)
10915}
10916
10917fn graph_db_backend_eval_full_projection_source_watermark(
10918    root: &Path,
10919    scope: Option<&str>,
10920) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10921    let path_hint = root;
10922    let mut detail_parts = Vec::new();
10923    let mut parts = vec![
10924        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10925        format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10926        "watermark_kind:stable_full_projection_inputs".to_string(),
10927        format!("scope:{}", scope.unwrap_or("root")),
10928        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10929    ];
10930
10931    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10932    match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10933        Some(db_path) => {
10934            let db = index::IndexDb::open_read_only_resilient(db_path)?;
10935            parts.push("index_mode:indexed".to_string());
10936            detail_parts.push("mode=indexed".to_string());
10937            parts.push(format!(
10938                "index_source_root:{}",
10939                traversal_watermark_path(root, &gate.source_root)
10940            ));
10941
10942            let symbols = db
10943                .all_symbols()?
10944                .into_iter()
10945                .filter(|symbol| {
10946                    !traversal_path_is_generated_artifact(
10947                        root,
10948                        &gate.source_root,
10949                        Path::new(&symbol.file),
10950                    ) && !traversal_path_is_session_markdown(
10951                        root,
10952                        &gate.source_root,
10953                        Path::new(&symbol.file),
10954                    )
10955                })
10956                .collect::<Vec<_>>();
10957            let symbols_hash = content_hash(&symbols)?;
10958            detail_parts.push(format!("symbols={symbols_hash}"));
10959            parts.push(format!("index_symbols:{symbols_hash}"));
10960
10961            let edges = db
10962                .all_stored_edges()?
10963                .into_iter()
10964                .filter(|edge| {
10965                    !traversal_path_is_generated_artifact(
10966                        root,
10967                        &gate.source_root,
10968                        Path::new(&edge.caller_file),
10969                    ) && !traversal_path_is_session_markdown(
10970                        root,
10971                        &gate.source_root,
10972                        Path::new(&edge.caller_file),
10973                    )
10974                })
10975                .collect::<Vec<_>>();
10976            let edges_hash = content_hash(&edges)?;
10977            detail_parts.push(format!("call_edges={edges_hash}"));
10978            parts.push(format!("index_call_edges:{edges_hash}"));
10979
10980            let routes = db
10981                .all_routes()?
10982                .into_iter()
10983                .filter(|route| {
10984                    !traversal_path_is_generated_artifact(
10985                        root,
10986                        &gate.source_root,
10987                        Path::new(&route.file),
10988                    ) && !traversal_path_is_session_markdown(
10989                        root,
10990                        &gate.source_root,
10991                        Path::new(&route.file),
10992                    )
10993                })
10994                .collect::<Vec<_>>();
10995            let routes_hash = content_hash(&routes)?;
10996            detail_parts.push(format!("routes={routes_hash}"));
10997            parts.push(format!("index_routes:{routes_hash}"));
10998        }
10999        None => {
11000            parts.push("index_mode:raw_fallback".to_string());
11001            detail_parts.push("mode=raw_fallback".to_string());
11002            parts.push(format!(
11003                "raw_source_root:{}",
11004                traversal_watermark_path(root, &gate.source_root)
11005            ));
11006            let raw_rows =
11007                graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
11008            let raw_hash = content_hash(&raw_rows)?;
11009            detail_parts.push(format!("raw_source_files={raw_hash}"));
11010            parts.push(format!("raw_source_files:{raw_hash}"));
11011        }
11012    }
11013
11014    parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
11015    detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
11016    let summaries_start = parts.len();
11017    push_traversal_summaries_watermark_part(root, &mut parts)?;
11018    let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
11019    detail_parts.push(format!("summaries={summaries_hash}"));
11020    let value = content_hash(&parts)?;
11021    detail_parts.push(format!("watermark={value}"));
11022    Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
11023        value,
11024        detail: detail_parts.join(" "),
11025    })
11026}
11027
11028fn graph_db_backend_eval_full_projection_cache_key(
11029    root: &Path,
11030    scope: Option<&str>,
11031) -> Result<(String, String, String)> {
11032    let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
11033    let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
11034        root,
11035        scope,
11036        &source_watermark.value,
11037    )?;
11038    Ok((source_watermark.value, key, source_watermark.detail))
11039}
11040
11041fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
11042    root: &Path,
11043    scope: Option<&str>,
11044    source_watermark: &str,
11045) -> Result<String> {
11046    content_hash(&serde_json::json!({
11047    "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
11048    "root": root.display().to_string(),
11049    "scope": scope.unwrap_or("root"),
11050    "source_watermark": source_watermark,
11051    }))
11052}
11053
11054pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
11055    root: &Path,
11056    scope: Option<&str>,
11057) -> Result<(
11058    GraphProjection,
11059    Vec<String>,
11060    Vec<GraphDbBackendEvalPhaseTiming>,
11061    GraphDbBackendEvalFullProjectionCacheStats,
11062)> {
11063    let (source_watermark, key, source_watermark_detail) =
11064        graph_db_backend_eval_full_projection_cache_key(root, scope)?;
11065    let lookup_started = Instant::now();
11066    if let Some((cached, disk_bytes, json_bytes, read_profile)) =
11067        graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
11068            root,
11069            "full_projection",
11070            &key,
11071        )
11072        && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
11073        && cached.key == key
11074        && cached.source_watermark == source_watermark
11075    {
11076        let lookup_overhead_micros = lookup_started
11077            .elapsed()
11078            .as_micros()
11079            .saturating_sub(read_profile.file_read_micros)
11080            .saturating_sub(read_profile.gzip_decode_micros)
11081            .saturating_sub(read_profile.serde_decode_micros);
11082        let prune_started = Instant::now();
11083        let (pruned_files, pruned_bytes) =
11084            graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
11085        let prune_micros = prune_started.elapsed().as_micros();
11086        let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
11087            hit: true,
11088            disk_bytes,
11089            json_bytes,
11090            pruned_files,
11091            pruned_bytes,
11092        };
11093        let read_detail_suffix = if read_profile.legacy {
11094            " (legacy uncompressed cache path)"
11095        } else {
11096            ""
11097        };
11098        return Ok((
11099            cached.projection,
11100            cached.warnings,
11101            vec![
11102                graph_db_backend_eval_phase_timing(
11103                    "full_projection.cache_lookup",
11104                    lookup_overhead_micros,
11105                    &format!(
11106                        "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
11107                    ),
11108                ),
11109                graph_db_backend_eval_phase_timing(
11110                    "full_projection.cache.file_read",
11111                    read_profile.file_read_micros,
11112                    &format!(
11113                        "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
11114                    ),
11115                ),
11116                graph_db_backend_eval_phase_timing(
11117                    "full_projection.cache.gzip_decode",
11118                    read_profile.gzip_decode_micros,
11119                    "gunzip the compressed projection cache bytes",
11120                ),
11121                graph_db_backend_eval_phase_timing(
11122                    "full_projection.cache.serde_decode",
11123                    read_profile.serde_decode_micros,
11124                    "serde_json deserialize the decoded projection cache payload",
11125                ),
11126                graph_db_backend_eval_phase_timing(
11127                    "full_projection.cache.prune",
11128                    prune_micros,
11129                    "prune sibling cache files older than the current key",
11130                ),
11131                graph_db_backend_eval_phase_timing(
11132                    "full_projection.source_graph_build",
11133                    0,
11134                    "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
11135                ),
11136                graph_db_backend_eval_phase_timing(
11137                    "full_projection.projection_rows",
11138                    0,
11139                    "reused cached provider-neutral full-project projection rows",
11140                ),
11141            ],
11142            cache_stats,
11143        ));
11144    }
11145
11146    let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
11147    let mut phases = vec![graph_db_backend_eval_phase_timing(
11148        "full_projection.cache_lookup",
11149        lookup_started.elapsed().as_micros(),
11150        &format!(
11151            "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
11152        ),
11153    )];
11154    let full_source = graph_db_backend_eval_timed_phase(
11155        &mut phases,
11156        "full_projection.source_graph_build",
11157        "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
11158        || build_traversal_graph_source_with_options(root, root, scope, false),
11159    )?;
11160    let projection = graph_db_backend_eval_timed_phase(
11161        &mut phases,
11162        "full_projection.projection_rows",
11163        "provider-neutral row construction for the opt-in full-project projection dataset",
11164        || traversal_projection_from_graph(root, scope, &full_source),
11165    )?;
11166    let warnings = full_source.warnings;
11167    let refreshed_source_watermark =
11168        graph_db_backend_eval_full_projection_source_watermark(root, scope)
11169            .map(|watermark| watermark.value)
11170            .unwrap_or_else(|_| source_watermark.clone());
11171    let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
11172        root,
11173        scope,
11174        &refreshed_source_watermark,
11175    )?;
11176    let cache = GraphDbBackendEvalFullProjectionCache {
11177        version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
11178        key: write_key.clone(),
11179        source_watermark: refreshed_source_watermark,
11180        projection: projection.clone(),
11181        warnings: warnings.clone(),
11182    };
11183    if let Some((disk_bytes, json_bytes, write_profile)) =
11184        graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
11185    {
11186        cache_stats.disk_bytes = disk_bytes;
11187        cache_stats.json_bytes = json_bytes;
11188        phases.push(graph_db_backend_eval_phase_timing(
11189            "full_projection.cache.serde_encode",
11190            write_profile.serde_encode_micros,
11191            "serde_json serialize the projection cache payload before compression",
11192        ));
11193        phases.push(graph_db_backend_eval_phase_timing(
11194            "full_projection.cache.gzip_encode",
11195            write_profile.gzip_encode_micros,
11196            "gzip-compress the serialized projection cache payload",
11197        ));
11198        phases.push(graph_db_backend_eval_phase_timing(
11199            "full_projection.cache.file_write",
11200            write_profile.file_write_micros,
11201            "write the compressed projection cache bytes to .tsift/backend-eval-cache",
11202        ));
11203    }
11204    let prune_started = Instant::now();
11205    let (pruned_files, pruned_bytes) =
11206        graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
11207    phases.push(graph_db_backend_eval_phase_timing(
11208        "full_projection.cache.prune",
11209        prune_started.elapsed().as_micros(),
11210        "prune sibling cache files older than the current key",
11211    ));
11212    cache_stats.pruned_files = pruned_files;
11213    cache_stats.pruned_bytes = pruned_bytes;
11214    Ok((projection, warnings, phases, cache_stats))
11215}
11216
11217fn graph_db_backend_eval_timed(
11218    name: &str,
11219    run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
11220) -> (
11221    GraphDbBackendEvalOperation,
11222    Option<GraphDbBackendEvalSignature>,
11223) {
11224    let started = Instant::now();
11225    match run() {
11226        Ok((rows, value)) => (
11227            GraphDbBackendEvalOperation {
11228                name: name.to_string(),
11229                supported: true,
11230                status: "ok".to_string(),
11231                duration_micros: started.elapsed().as_micros(),
11232                rows,
11233                error: None,
11234            },
11235            Some(GraphDbBackendEvalSignature {
11236                operation: name.to_string(),
11237                value,
11238            }),
11239        ),
11240        Err(err) => (
11241            GraphDbBackendEvalOperation {
11242                name: name.to_string(),
11243                supported: false,
11244                status: "error".to_string(),
11245                duration_micros: started.elapsed().as_micros(),
11246                rows: None,
11247                error: Some(format!("{err:#}")),
11248            },
11249            None,
11250        ),
11251    }
11252}
11253
11254fn graph_db_backend_eval_parity(
11255    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11256    candidate_signatures: &[GraphDbBackendEvalSignature],
11257) -> GraphDbBackendEvalParity {
11258    let Some(sqlite_signatures) = sqlite_signatures else {
11259        return GraphDbBackendEvalParity {
11260            matches_sqlite: true,
11261            diagnostics: Vec::new(),
11262        };
11263    };
11264    let sqlite = sqlite_signatures
11265        .iter()
11266        .map(|signature| (signature.operation.as_str(), &signature.value))
11267        .collect::<BTreeMap<_, _>>();
11268    let candidate = candidate_signatures
11269        .iter()
11270        .map(|signature| (signature.operation.as_str(), &signature.value))
11271        .collect::<BTreeMap<_, _>>();
11272    let mut diagnostics = Vec::new();
11273    for (operation, sqlite_value) in sqlite {
11274        match candidate.get(operation) {
11275            Some(candidate_value) if *candidate_value == sqlite_value => {}
11276            Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
11277            None => diagnostics.push(format!(
11278                "{operation} did not complete for candidate backend"
11279            )),
11280        }
11281    }
11282    GraphDbBackendEvalParity {
11283        matches_sqlite: diagnostics.is_empty(),
11284        diagnostics,
11285    }
11286}
11287
11288pub(crate) fn graph_db_backend_eval_targets(
11289    store: &impl GraphStore,
11290    requested: &[String],
11291) -> Result<Vec<String>> {
11292    let requested = requested
11293        .iter()
11294        .filter_map(|target| normalize_conflict_target(target))
11295        .collect::<Vec<_>>();
11296    if !requested.is_empty() {
11297        return Ok(requested);
11298    }
11299
11300    for kind in ["backlog", "job_packet"] {
11301        let nodes = store.nodes_by_kind(kind)?;
11302        if let Some(node) = nodes.first() {
11303            if let Some(ref_id) = node.properties.get("ref_id") {
11304                return Ok(vec![ref_id.clone()]);
11305            }
11306            return Ok(vec![node.id.clone()]);
11307        }
11308    }
11309    Ok(Vec::new())
11310}
11311
11312fn graph_db_backend_eval_path_targets(
11313    store: &impl GraphStore,
11314    max_hops: usize,
11315) -> Result<Option<(String, String, usize)>> {
11316    let synthetic_from = "gsym-synthetic-0000";
11317    let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
11318    if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
11319        let outgoing = store.outgoing_edges(synthetic_from, None)?;
11320        if outgoing.len() > 1
11321            && let Some(edge) = outgoing.first()
11322        {
11323            return Ok(Some((
11324                edge.from_id.clone(),
11325                edge.to_id.clone(),
11326                GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11327            )));
11328        }
11329        return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
11330    }
11331
11332    Ok(store.sample_edge(None)?.map(|edge| {
11333        (
11334            edge.from_id,
11335            edge.to_id,
11336            GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11337        )
11338    }))
11339}
11340
11341fn graph_db_backend_eval_path_operation<S: GraphStore>(
11342    store: &S,
11343    configured_max_hops: usize,
11344) -> (
11345    GraphDbBackendEvalOperation,
11346    Option<GraphDbBackendEvalSignature>,
11347) {
11348    let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11349        "path_max_hops".to_string()
11350    } else {
11351        format!("path_max_hops_{configured_max_hops}")
11352    };
11353    graph_db_backend_eval_timed(&operation_name, || {
11354        let (from, to, effective_max_hops) =
11355            graph_db_backend_eval_path_targets(store, configured_max_hops)?
11356                .context("backend-eval path probe requires at least one traversable edge")?;
11357        let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
11358        let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11359            Some(format!(
11360                "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
11361                GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
11362            ))
11363        } else if path.is_none() && effective_max_hops == configured_max_hops {
11364            Some(format!(
11365                "path probe truncated at {configured_max_hops} hops before a route was found"
11366            ))
11367        } else {
11368            None
11369        };
11370        Ok((
11371            path.as_ref().map(|path| path.nodes.len()),
11372            serde_json::json!({
11373                "from": from,
11374                "to": to,
11375                "configured_max_hops": configured_max_hops,
11376                "effective_max_hops": effective_max_hops,
11377                "hops": path.as_ref().map(|path| path.hops),
11378                "nodes": path.as_ref().map(|path| &path.nodes),
11379                "found": path.is_some(),
11380                "warning": warning,
11381            }),
11382        ))
11383    })
11384}
11385
11386fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
11387    store: &S,
11388    depth: usize,
11389    limit: usize,
11390) -> (
11391    GraphDbBackendEvalOperation,
11392    Option<GraphDbBackendEvalSignature>,
11393) {
11394    graph_db_backend_eval_timed("neighborhood", || {
11395        let edge = match store.sample_edge(Some("calls"))? {
11396            Some(edge) => edge,
11397            None => store.sample_edge(None)?.context(
11398                "backend-eval neighborhood probe requires at least one traversable edge",
11399            )?,
11400        };
11401        let page = store
11402            .paged_neighborhood(
11403                &edge.from_id,
11404                depth,
11405                Some(&edge.kind),
11406                GraphQueryOptions {
11407                    limit: Some(limit.max(1)),
11408                    ..GraphQueryOptions::default()
11409                },
11410            )?
11411            .with_context(|| {
11412                format!(
11413                    "backend-eval neighborhood target not found: {}",
11414                    edge.from_id
11415                )
11416            })?;
11417        Ok((
11418            Some(page.nodes.len() + page.edges.len()),
11419            serde_json::json!({
11420                "center": edge.from_id,
11421                "kind": edge.kind,
11422                "depth": depth,
11423                "limit": limit.max(1),
11424                "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11425                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11426                "truncated": page.page.truncated,
11427            }),
11428        ))
11429    })
11430}
11431
11432fn graph_db_backend_eval_related_operation<S: GraphStore>(
11433    root: &Path,
11434    scope: Option<&str>,
11435    store: &S,
11436    depth: usize,
11437    limit: usize,
11438) -> (
11439    GraphDbBackendEvalOperation,
11440    Option<GraphDbBackendEvalSignature>,
11441) {
11442    graph_db_backend_eval_timed("related", || {
11443        let query = "backend evaluation";
11444        let semantic = semantic_related_report_from_store(
11445            root,
11446            scope,
11447            query,
11448            3,
11449            SemanticRelatedKind::All,
11450            store,
11451        )?;
11452        let seed_ids = semantic
11453            .items
11454            .iter()
11455            .map(|item| item.handle.clone())
11456            .collect::<Vec<_>>();
11457        let subgraph =
11458            graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
11459        Ok((
11460            Some(subgraph.nodes.len() + subgraph.edges.len()),
11461            serde_json::json!({
11462                "query": query,
11463                "seed_ids": seed_ids,
11464                "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11465                "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11466                "truncated": subgraph.truncated,
11467                "warnings": semantic.warnings,
11468                "diagnostics": subgraph.diagnostics,
11469            }),
11470        ))
11471    })
11472}
11473
11474fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
11475    serde_json::json!({
11476        "target": report.target,
11477        "target_node_id": report.target_node.id,
11478        "target_kind": report.target_node.kind,
11479        "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
11480        "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
11481        "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
11482        "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
11483        "path_count": report.shortest_paths.len(),
11484    })
11485}
11486
11487fn graph_db_backend_eval_target_resolution_signature(
11488    resolved: &[(String, SubstrateGraphNode)],
11489) -> serde_json::Value {
11490    serde_json::json!({
11491        "targets": resolved.iter().map(|(target, node)| {
11492            serde_json::json!({
11493                "target": target,
11494                "target_node_id": node.id,
11495                "target_kind": node.kind,
11496                "target_label": node.label,
11497            })
11498        }).collect::<Vec<_>>(),
11499    })
11500}
11501
11502fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
11503    serde_json::json!({
11504        "targets": report.targets,
11505        "can_parallel": report.can_parallel,
11506        "fail_closed": report.fail_closed,
11507        "cross_target_parallel_safe": report.cross_target_parallel_safe,
11508        "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
11509        "candidates": report.candidates.iter().map(|candidate| {
11510            serde_json::json!({
11511                "target": candidate.target,
11512                "risk": conflict_risk_label(candidate.risk),
11513                "owned_files": candidate.owned_files,
11514                "owned_symbols": candidate.owned_symbols,
11515                "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
11516                "previously_completed": candidate.previously_completed,
11517                "parallel_safe": candidate.parallel_safe,
11518            })
11519        }).collect::<Vec<_>>(),
11520        "conflicts": report.conflicts.iter().map(|pair| {
11521            serde_json::json!({
11522                "left": pair.left,
11523                "right": pair.right,
11524                "risk": conflict_risk_label(pair.risk),
11525            })
11526        }).collect::<Vec<_>>(),
11527    })
11528}
11529
11530fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
11531    serde_json::json!({
11532        "targets": report.targets,
11533        "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11534        "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
11535        "evidence_packet_ids": report.evidence_packet_ids,
11536        "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
11537        "truncated": report.truncated,
11538    })
11539}
11540
11541fn graph_db_backend_eval_edge_scan_probe(
11542    store: &impl GraphStore,
11543) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
11544    if let Some((edge, filter)) = store.sample_edge_with_property()? {
11545        return Ok((edge, vec![filter]));
11546    }
11547    let edge = store
11548        .sample_edge(None)?
11549        .context("backend-eval edge scan requires at least one edge")?;
11550    Ok((edge, Vec::new()))
11551}
11552
11553#[allow(clippy::too_many_arguments)]
11554fn graph_db_backend_eval_report_for_store<S: GraphStore>(
11555    backend: &str,
11556    adapter: &str,
11557    read_only: bool,
11558    root: &Path,
11559    path: &Path,
11560    scope: Option<&str>,
11561    targets: &[String],
11562    depth: usize,
11563    limit: usize,
11564    impact_limit: usize,
11565    store: &S,
11566    freshness: GraphDbFreshnessReport,
11567    refresh_operation: GraphDbBackendEvalOperation,
11568    refresh_signature: Option<GraphDbBackendEvalSignature>,
11569    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11570    extra_warnings: Vec<String>,
11571    prepared: &ConflictMatrixPreparedInputs,
11572    projection_load: &str,
11573    lock_behavior: &str,
11574    install_portability: &str,
11575) -> (
11576    GraphDbBackendEvalBackendReport,
11577    Vec<GraphDbBackendEvalSignature>,
11578) {
11579    let mut operations = vec![refresh_operation];
11580    let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
11581
11582    let (operation, signature) = graph_db_backend_eval_timed("status", || {
11583        let (nodes, edges) = store.graph_counts()?;
11584        Ok((
11585            Some(nodes + edges),
11586            serde_json::json!({
11587                "freshness": freshness.status,
11588                "nodes": nodes,
11589                "edges": edges,
11590            }),
11591        ))
11592    });
11593    operations.push(operation);
11594    signatures.extend(signature);
11595
11596    let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
11597        let edge = store
11598            .sample_edge(None)?
11599            .context("backend-eval edge lookup requires at least one edge")?;
11600        let edge_id = graph_db_edge_key(&edge);
11601        let found = store
11602            .edge(&edge_id)?
11603            .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
11604        Ok((
11605            Some(1),
11606            serde_json::json!({
11607                "edge_id": edge_id,
11608                "from_id": found.from_id,
11609                "to_id": found.to_id,
11610                "kind": found.kind,
11611            }),
11612        ))
11613    });
11614    operations.push(operation);
11615    signatures.extend(signature);
11616
11617    let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
11618        let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
11619        let page = store.paged_edges(
11620            Some(&edge.kind),
11621            GraphQueryOptions {
11622                limit: Some(limit.max(1)),
11623                property_filters: filters.clone(),
11624                ..GraphQueryOptions::default()
11625            },
11626        )?;
11627        Ok((
11628            Some(page.edges.len()),
11629            serde_json::json!({
11630                "kind": edge.kind,
11631                "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
11632                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11633                "truncated": page.page.truncated,
11634            }),
11635        ))
11636    });
11637    operations.push(operation);
11638    signatures.extend(signature);
11639
11640    let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
11641        let edge = store
11642            .sample_edge(None)?
11643            .context("backend-eval incident edge scan requires at least one edge")?;
11644        let page = store.paged_incident_edges(
11645            &edge.from_id,
11646            Some(&edge.kind),
11647            GraphQueryOptions {
11648                limit: Some(limit.max(1)),
11649                ..GraphQueryOptions::default()
11650            },
11651        )?;
11652        Ok((
11653            Some(page.edges.len()),
11654            serde_json::json!({
11655                "node_id": edge.from_id,
11656                "kind": edge.kind,
11657                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11658                "truncated": page.page.truncated,
11659            }),
11660        ))
11661    });
11662    operations.push(operation);
11663    signatures.extend(signature);
11664
11665    let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11666    operations.push(operation);
11667    signatures.extend(signature);
11668
11669    let (operation, signature) =
11670        graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11671    operations.push(operation);
11672    signatures.extend(signature);
11673
11674    for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11675        .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11676    {
11677        let (operation, signature) =
11678            graph_db_backend_eval_path_operation(store, configured_max_hops);
11679        operations.push(operation);
11680        signatures.extend(signature);
11681    }
11682
11683    let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11684        let resolved = targets
11685            .iter()
11686            .map(|target| {
11687                let node = graph_db_resolve_evidence_target(store, target)?
11688                    .with_context(|| format!("backend-eval target not found: {target}"))?;
11689                Ok((target.clone(), node))
11690            })
11691            .collect::<Result<Vec<_>>>()?;
11692        let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11693        Ok((Some(resolved.len()), signature))
11694    });
11695    operations.push(operation);
11696    signatures.extend(signature);
11697
11698    let mut evidence_for_report = None;
11699    let mut graph_snapshot_for_trace = None;
11700    let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11701        let resolved_targets =
11702            resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11703        let evidence = collect_conflict_matrix_evidence_packets(
11704            root,
11705            scope,
11706            backend,
11707            &resolved_targets,
11708            depth,
11709            limit,
11710            store,
11711            freshness.clone(),
11712        )?;
11713        let report = &evidence
11714            .first()
11715            .context("backend-eval evidence requires at least one target")?
11716            .report;
11717        let rows = evidence
11718            .iter()
11719            .map(|entry| {
11720                entry.report.worker_context.len()
11721                    + entry.report.source_handles.len()
11722                    + entry.report.worker_results.len()
11723                    + entry.report.semantic_related.len()
11724            })
11725            .sum();
11726        let signature = graph_db_backend_eval_evidence_signature(report);
11727        evidence_for_report = Some((resolved_targets, evidence));
11728        Ok((Some(rows), signature))
11729    });
11730    operations.push(operation);
11731    signatures.extend(signature);
11732
11733    let mut conflict_for_trace = None;
11734    let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11735        let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11736            let graph =
11737                conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11738            let shared_preparation =
11739                conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11740            ConflictMatrixGraphPreparedInputs {
11741                targets,
11742                graph,
11743                evidence,
11744                shared_preparation,
11745            }
11746        } else {
11747            prepare_conflict_matrix_graph_orchestration(
11748                root,
11749                scope,
11750                backend,
11751                targets,
11752                prepared,
11753                depth,
11754                limit,
11755                store,
11756                freshness.clone(),
11757            )?
11758        };
11759        let report = build_conflict_matrix_report_from_prepared_graph(
11760            root,
11761            path,
11762            scope,
11763            depth,
11764            limit,
11765            impact_limit,
11766            freshness.clone(),
11767            extra_warnings.clone(),
11768            prepared,
11769            &graph_prepared,
11770        )?;
11771        let signature = graph_db_backend_eval_conflict_signature(&report);
11772        let rows = report.candidates.len() + report.conflicts.len();
11773        conflict_for_trace = Some(report);
11774        graph_snapshot_for_trace = Some(graph_prepared.graph);
11775        Ok((Some(rows), signature))
11776    });
11777    operations.push(operation);
11778    signatures.extend(signature);
11779
11780    let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11781        let conflict = conflict_for_trace
11782            .take()
11783            .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11784        let graph = graph_snapshot_for_trace
11785            .take()
11786            .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11787        let report = build_dispatch_trace_report_from_conflict_snapshot(
11788            root,
11789            scope,
11790            conflict,
11791            graph.nodes,
11792            graph.edges,
11793            depth,
11794            limit,
11795            Vec::new(),
11796        )?;
11797        Ok((
11798            Some(report.nodes.len() + report.edges.len()),
11799            graph_db_backend_eval_dispatch_signature(&report),
11800        ))
11801    });
11802    operations.push(operation);
11803    signatures.extend(signature);
11804
11805    let total_micros = operations
11806        .iter()
11807        .map(|operation| operation.duration_micros)
11808        .sum();
11809    let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11810    (
11811        GraphDbBackendEvalBackendReport {
11812            backend: backend.to_string(),
11813            adapter: adapter.to_string(),
11814            read_only,
11815            projection_load: projection_load.to_string(),
11816            operations,
11817            total_micros,
11818            parity,
11819            lock_behavior: lock_behavior.to_string(),
11820            install_portability: install_portability.to_string(),
11821        },
11822        signatures,
11823    )
11824}
11825
11826pub(crate) fn graph_db_backend_eval_refresh_operation(
11827    duration_micros: u128,
11828    rows: usize,
11829    value: serde_json::Value,
11830) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11831    (
11832        GraphDbBackendEvalOperation {
11833            name: "refresh".to_string(),
11834            supported: true,
11835            status: "ok".to_string(),
11836            duration_micros,
11837            rows: Some(rows),
11838            error: None,
11839        },
11840        GraphDbBackendEvalSignature {
11841            operation: "refresh".to_string(),
11842            value,
11843        },
11844    )
11845}
11846
11847pub(crate) fn graph_db_backend_eval_synthetic_projection(
11848    nodes: usize,
11849    fanout: usize,
11850) -> GraphProjection {
11851    let nodes = nodes.max(12);
11852    let symbol_count = nodes.saturating_sub(9).max(1);
11853    let source = GraphProvenance::new("backend-eval", "synthetic");
11854    let mut projection_nodes = vec![
11855        SubstrateGraphNode::new(
11856            "projection:tsift-traversal:synthetic",
11857            GRAPH_PROJECTION_META_KIND,
11858            "synthetic projection",
11859        )
11860        .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11861        .with_property(
11862            "content_hash",
11863            format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11864        )
11865        .with_provenance(source.clone()),
11866        SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11867            .with_property("ref_id", "synthetic-session"),
11868        SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11869            .with_property("ref_id", "synthetic")
11870            .with_property("path", "tasks/software/synthetic.md")
11871            .with_property("line", "1")
11872            .with_property(
11873                "expand",
11874                "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11875            ),
11876        SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11877            .with_property("ref_id", "synthetic"),
11878        SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11879            .with_property("target", "synthetic")
11880            .with_property("summary", "Synthetic worker owns synthetic.rs")
11881            .with_property(
11882                "expand",
11883                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11884            ),
11885        SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11886            .with_property("file", "synthetic.rs")
11887            .with_property("start", "1")
11888            .with_property("end", "80")
11889            .with_property(
11890                "expand",
11891                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11892            ),
11893        SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11894            .with_property("path", "synthetic.rs"),
11895        SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11896            .with_property("handle", "gsem-synthetic")
11897            .with_property("label", "backend evaluation")
11898            .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11899            .with_property(
11900                "embedding",
11901                semantic_embedding_property("backend evaluation"),
11902            ),
11903        SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11904            .with_property("ref_id", "synthetic")
11905            .with_property("status", "completed")
11906            .with_property("touched_files", "synthetic.rs")
11907            .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11908    ];
11909    for idx in 0..symbol_count {
11910        projection_nodes.push(
11911            SubstrateGraphNode::new(
11912                format!("gsym-synthetic-{idx:04}"),
11913                "symbol",
11914                format!("synthetic_symbol_{idx:04}"),
11915            )
11916            .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11917            .with_property("path", "synthetic.rs")
11918            .with_property("line", (idx + 1).to_string()),
11919        );
11920    }
11921
11922    let mut projection_edges = vec![
11923        SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11924        SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11925        SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11926        SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11927        SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11928        SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11929        SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11930        SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11931        SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11932    ];
11933    for idx in 0..symbol_count {
11934        let from = format!("gsym-synthetic-{idx:04}");
11935        for offset in 1..=fanout.max(1).min(symbol_count) {
11936            let to_idx = (idx + offset) % symbol_count;
11937            if to_idx != idx {
11938                projection_edges.push(SubstrateGraphEdge::new(
11939                    from.clone(),
11940                    format!("gsym-synthetic-{to_idx:04}"),
11941                    "calls",
11942                ));
11943            }
11944        }
11945    }
11946
11947    GraphProjection {
11948        nodes: projection_nodes,
11949        edges: projection_edges
11950            .into_iter()
11951            .map(|edge| {
11952                edge.with_property("dataset", "synthetic")
11953                    .with_provenance(source.clone())
11954            })
11955            .collect(),
11956    }
11957}
11958
11959pub(crate) fn graph_db_backend_eval_promotion(
11960    datasets: &[GraphDbBackendEvalDataset],
11961    candidates: &[GraphDbExperimentalBackend],
11962) -> Vec<GraphDbBackendPromotionDecision> {
11963    let mut decisions = Vec::new();
11964    for candidate in candidates {
11965        let mut reasons = Vec::new();
11966        let mut faster_everywhere = true;
11967        let mut parity_everywhere = true;
11968        for dataset in datasets {
11969            let Some(sqlite_report) = dataset
11970                .backends
11971                .iter()
11972                .find(|backend| backend.backend == "sqlite")
11973            else {
11974                parity_everywhere = false;
11975                faster_everywhere = false;
11976                reasons.push(format!(
11977                    "{} dataset is missing SQLite baseline",
11978                    dataset.name
11979                ));
11980                continue;
11981            };
11982            let sqlite_total = sqlite_report.total_micros;
11983            let Some(candidate_report) = dataset
11984                .backends
11985                .iter()
11986                .find(|backend| backend.backend == candidate.name())
11987            else {
11988                parity_everywhere = false;
11989                reasons.push(format!("{} dataset did not run", dataset.name));
11990                continue;
11991            };
11992            if !candidate_report.parity.matches_sqlite {
11993                parity_everywhere = false;
11994                reasons.push(format!("{} parity differed from SQLite", dataset.name));
11995            }
11996            if candidate_report.total_micros >= sqlite_total {
11997                faster_everywhere = false;
11998                reasons.push(format!(
11999                    "{} total {}us did not beat SQLite {}us",
12000                    dataset.name, candidate_report.total_micros, sqlite_total
12001                ));
12002            }
12003            let sqlite_operations = sqlite_report
12004                .operations
12005                .iter()
12006                .map(|operation| (operation.name.as_str(), operation.duration_micros))
12007                .collect::<BTreeMap<_, _>>();
12008            for operation in &candidate_report.operations {
12009                if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
12010                    && operation.duration_micros >= *sqlite_duration
12011                {
12012                    faster_everywhere = false;
12013                    reasons.push(format!(
12014                        "{} {} operation {}us did not beat SQLite {}us",
12015                        dataset.name, operation.name, operation.duration_micros, sqlite_duration
12016                    ));
12017                }
12018            }
12019            if candidate_report
12020                .operations
12021                .iter()
12022                .any(|operation| operation.status != "ok")
12023            {
12024                parity_everywhere = false;
12025                reasons.push(format!("{} has failed benchmark operations", dataset.name));
12026            }
12027        }
12028        let decision = if let Some(reason) = candidate.prototype_hold_reason() {
12029            reasons.push(reason.to_string());
12030            reasons.push(
12031                "current bounded prototype timings are benchmark evidence, not a backend switch approval"
12032                    .to_string(),
12033            );
12034            "hold"
12035        } else if parity_everywhere && faster_everywhere {
12036            reasons.push(
12037                "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
12038                    .to_string(),
12039            );
12040            "eligible"
12041        } else {
12042            reasons.push(
12043                "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
12044                    .to_string(),
12045            );
12046            "hold"
12047        };
12048        decisions.push(GraphDbBackendPromotionDecision {
12049            backend: candidate.name().to_string(),
12050            decision: decision.to_string(),
12051            reasons: dedupe_preserve_order(reasons),
12052            gate: candidate.promotion_gate(),
12053        });
12054    }
12055    decisions
12056}
12057
12058pub(crate) fn graph_db_backend_eval_metrics(
12059    datasets: &[GraphDbBackendEvalDataset],
12060) -> BTreeMap<String, f64> {
12061    let mut metrics = BTreeMap::new();
12062    for dataset in datasets {
12063        let graph_rows = graph_db_backend_eval_graph_rows(dataset);
12064        metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
12065        metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
12066        metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
12067        for backend in &dataset.backends {
12068            let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
12069            metrics.insert(
12070                format!("{prefix}.total_duration_micros"),
12071                backend.total_micros as f64,
12072            );
12073            append_graph_db_backend_eval_normalized_duration_metric(
12074                &mut metrics,
12075                &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
12076                backend.total_micros,
12077                graph_rows,
12078            );
12079            for operation in &backend.operations {
12080                metrics.insert(
12081                    format!("{prefix}.{}.duration_micros", operation.name),
12082                    operation.duration_micros as f64,
12083                );
12084                append_graph_db_backend_eval_normalized_duration_metric(
12085                    &mut metrics,
12086                    &format!(
12087                        "{prefix}.{}.duration_micros_per_1k_graph_rows",
12088                        operation.name
12089                    ),
12090                    operation.duration_micros,
12091                    graph_rows,
12092                );
12093                if let Some(rows) = operation.rows {
12094                    metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
12095                }
12096            }
12097        }
12098    }
12099    metrics
12100}
12101
12102pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
12103    dataset.nodes + dataset.edges
12104}
12105
12106pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
12107    metrics: &mut BTreeMap<String, f64>,
12108    key: &str,
12109    duration_micros: u128,
12110    graph_rows: usize,
12111) {
12112    if graph_rows == 0 {
12113        return;
12114    }
12115    metrics.insert(
12116        key.to_string(),
12117        duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
12118    );
12119}
12120
12121pub(crate) fn append_graph_db_backend_eval_phase_metrics(
12122    metrics: &mut BTreeMap<String, f64>,
12123    dataset: &str,
12124    graph_rows: usize,
12125    phases: &[GraphDbBackendEvalPhaseTiming],
12126) {
12127    for phase in phases {
12128        metrics.insert(
12129            format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
12130            phase.duration_micros as f64,
12131        );
12132        append_graph_db_backend_eval_normalized_duration_metric(
12133            metrics,
12134            &format!(
12135                "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
12136                phase.name
12137            ),
12138            phase.duration_micros,
12139            graph_rows,
12140        );
12141    }
12142}
12143
12144fn graph_db_backend_eval_base_command(
12145    root: &Path,
12146    scope: Option<&str>,
12147    full_projection: bool,
12148) -> String {
12149    let full_projection_arg = if full_projection {
12150        " --full-projection"
12151    } else {
12152        ""
12153    };
12154    format!(
12155        "tsift graph-db --path {}{} --json backend-eval{}",
12156        shell_quote(root.to_string_lossy().as_ref()),
12157        graph_db_scope_arg(scope),
12158        full_projection_arg
12159    )
12160}
12161
12162pub(crate) fn graph_db_backend_eval_metric_digest_command(
12163    root: &Path,
12164    scope: Option<&str>,
12165    full_projection: bool,
12166) -> String {
12167    format!(
12168        "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12169        graph_db_backend_eval_base_command(root, scope, full_projection)
12170    )
12171}
12172
12173fn graph_db_backend_eval_repeated_sample_command(
12174    root: &Path,
12175    scope: Option<&str>,
12176    full_projection: bool,
12177) -> String {
12178    format!(
12179        "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12180        graph_db_backend_eval_base_command(root, scope, full_projection)
12181    )
12182}
12183
12184fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
12185    let mut required_metrics = Vec::new();
12186    for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
12187        required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
12188        required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
12189        for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
12190            required_metrics.push(format!(
12191                "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
12192            ));
12193            required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
12194        }
12195    }
12196    GraphDbHopCapPromotionGate {
12197        status: "hold_64_default_until_gate_passes".to_string(),
12198        current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
12199        candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
12200        required_backend: perf_gate::BASELINE_BACKEND.to_string(),
12201        required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
12202            .iter()
12203            .map(|workload| (*workload).to_string())
12204            .collect(),
12205        required_metrics,
12206        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12207        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12208        decision_rule:
12209            "keep 64 as the user-facing default until each candidate tier has repeated real, full_projection, and synthetic_deep_chain SQLite samples within the latency-regression budget and returning useful path rows; full_projection samples are binding only after a cold populate leg proves a cache-hit leg"
12210                .to_string(),
12211    }
12212}
12213
12214fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
12215    let candidate_backends = [
12216        GraphDbExperimentalBackend::Falkordb,
12217        GraphDbExperimentalBackend::Kuzu,
12218        GraphDbExperimentalBackend::Surrealdb,
12219    ]
12220    .into_iter()
12221    .map(|backend| GraphDbBackendAdapterSpikeCandidate {
12222        backend: backend.name().to_string(),
12223        adapter_label: backend.adapter_label().to_string(),
12224        projection_load: backend.projection_load().to_string(),
12225        lock_behavior: backend.lock_behavior().to_string(),
12226        install_portability: backend.install_portability().to_string(),
12227    })
12228    .collect();
12229
12230    GraphDbBackendAdapterSpikeGate {
12231        status: "hold_real_optional_adapter_required".to_string(),
12232        candidate_backends,
12233        required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
12234            .iter()
12235            .map(|workload| (*workload).to_string())
12236            .collect(),
12237        required_checks: vec![
12238            "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
12239            "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
12240            "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
12241            "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
12242            "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
12243                .to_string(),
12244            "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
12245            "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
12246        ],
12247        decision_rule:
12248            "do not promote a read-only prototype; FalkorDB, Kuzu, or SurrealDB can only advance after a real optional adapter proves projection writes/load, lock semantics, install portability, full parity, and faster-than-SQLite results across every required workload"
12249                .to_string(),
12250        evidence_plan: "plans/gback-evidence.md".to_string(),
12251    }
12252}
12253
12254pub(crate) fn graph_db_backend_eval_performance_gate(
12255    root: &Path,
12256    scope: Option<&str>,
12257    full_projection: bool,
12258) -> GraphDbBackendEvalPerformanceGate {
12259    let mut required_metrics = vec![
12260        "real.sqlite.refresh.duration_micros".to_string(),
12261        "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
12262        "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
12263        "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
12264        "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12265        "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12266        "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
12267        "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
12268        "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12269        "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
12270        "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
12271        "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
12272        "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
12273        "real.sqlite.conflict_matrix.duration_micros".to_string(),
12274        "real.sqlite.dispatch_trace.duration_micros".to_string(),
12275        "real.sqlite.path_max_hops.duration_micros".to_string(),
12276        "real.sqlite.path_max_hops_128.duration_micros".to_string(),
12277        "real.sqlite.path_max_hops_256.duration_micros".to_string(),
12278        "real.sqlite.path_max_hops_512.duration_micros".to_string(),
12279        "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
12280        "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
12281        "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
12282        "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
12283        "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12284        "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12285        "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
12286            .to_string(),
12287        "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12288            .to_string(),
12289        "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12290        "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12291        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
12292        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
12293        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
12294        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
12295        "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12296            .to_string(),
12297        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
12298        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
12299            .to_string(),
12300        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
12301            .to_string(),
12302        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
12303            .to_string(),
12304    ];
12305    if full_projection {
12306        required_metrics.extend([
12307            "full_projection.cache.hit".to_string(),
12308            "full_projection.cache.disk_bytes".to_string(),
12309            "full_projection.cache.compression_ratio".to_string(),
12310            "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
12311            "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12312            "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
12313                .to_string(),
12314            "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
12315                .to_string(),
12316            "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
12317            "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
12318            "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
12319            "full_projection.sqlite.neighborhood.duration_micros".to_string(),
12320            "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
12321            "full_projection.sqlite.evidence.duration_micros".to_string(),
12322            "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
12323            "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
12324            "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
12325            "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
12326            "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
12327            "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
12328        ]);
12329    }
12330    GraphDbBackendEvalPerformanceGate {
12331        baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
12332        ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
12333            .to_string(),
12334        opt_in_real_profile:
12335            "pass --full-projection to add the full-project dataset when checking for large projection regressions"
12336                .to_string(),
12337        full_projection_cache_hit_gate: if full_projection {
12338            "binding full_projection performance evidence requires a cold populate leg followed by cache-leg samples with full_projection.cache.hit=1; cache-miss samples are diagnostics, not backend or hop-cap promotion proof"
12339                .to_string()
12340        } else {
12341            "not evaluated until --full-projection is enabled".to_string()
12342        },
12343        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12344        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12345        normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
12346        required_metrics,
12347        digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
12348        repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
12349            root,
12350            scope,
12351            full_projection,
12352        ),
12353        hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
12354        backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
12355    }
12356}
12357
12358#[cfg(feature = "backend-surrealdb")]
12359fn graph_db_backend_eval_path_segment(value: &str) -> String {
12360    value
12361        .chars()
12362        .map(|ch| {
12363            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
12364                ch
12365            } else {
12366                '_'
12367            }
12368        })
12369        .collect()
12370}
12371
12372#[cfg(feature = "backend-surrealdb")]
12373fn graph_db_backend_eval_surrealdb_store_path(
12374    root: &Path,
12375    scope: Option<&str>,
12376    dataset: &str,
12377) -> PathBuf {
12378    root.join(".tsift/backend-eval-cache/surrealdb")
12379        .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
12380        .join(graph_db_backend_eval_path_segment(dataset))
12381        .join("surrealkv")
12382}
12383
12384pub(crate) struct GraphDbBackendEvalOptions<'a> {
12385    path: &'a Path,
12386    scope: Option<&'a str>,
12387    candidates: &'a [String],
12388    targets: &'a [String],
12389    full_projection: bool,
12390}
12391
12392#[allow(clippy::too_many_arguments)]
12393pub(crate) fn graph_db_backend_eval_dataset(
12394    name: &str,
12395    root: &Path,
12396    path: &Path,
12397    scope: Option<&str>,
12398    targets: &[String],
12399    depth: usize,
12400    limit: usize,
12401    impact_limit: usize,
12402    candidates: &[GraphDbExperimentalBackend],
12403    sqlite_store: &SqliteGraphStore,
12404    sqlite_freshness: GraphDbFreshnessReport,
12405    sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
12406    sqlite_rows: ConvexProjectionRows,
12407    extra_warnings: Vec<String>,
12408    prepared: &ConflictMatrixPreparedInputs,
12409) -> Result<GraphDbBackendEvalDataset> {
12410    let (nodes, edges) = sqlite_store.graph_counts()?;
12411    let (sqlite_operation, sqlite_signature) = sqlite_refresh;
12412    let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
12413        "sqlite",
12414        "SQLite GraphStore correctness baseline",
12415        false,
12416        root,
12417        path,
12418        scope,
12419        targets,
12420        depth,
12421        limit,
12422        impact_limit,
12423        sqlite_store,
12424        sqlite_freshness,
12425        sqlite_operation,
12426        Some(sqlite_signature),
12427        None,
12428        extra_warnings.clone(),
12429        prepared,
12430        "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
12431        "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
12432        "bundled rusqlite baseline; no external service or runtime required",
12433    );
12434
12435    let mut backends = vec![sqlite_report];
12436    for candidate in candidates {
12437        #[cfg(feature = "backend-surrealdb")]
12438        if *candidate == GraphDbExperimentalBackend::Surrealdb {
12439            let started = Instant::now();
12440            let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
12441            let (store, warm_start) =
12442                SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
12443            let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12444            let rows = candidate_nodes + candidate_edges;
12445            let mut refresh_meta = serde_json::json!({
12446                "nodes": candidate_nodes,
12447                "edges": candidate_edges,
12448            });
12449            if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
12450                refresh_meta["warm_start"] = serde_json::json!("cache_hit");
12451            }
12452            let refresh = graph_db_backend_eval_refresh_operation(
12453                started.elapsed().as_micros(),
12454                rows,
12455                refresh_meta,
12456            );
12457            let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12458            let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12459                candidate.name(),
12460                "SurrealDB SurrealKV optional adapter spike",
12461                false,
12462                root,
12463                path,
12464                scope,
12465                targets,
12466                depth,
12467                limit,
12468                impact_limit,
12469                &store,
12470                freshness,
12471                refresh.0,
12472                Some(refresh.1),
12473                Some(&sqlite_signatures),
12474                extra_warnings.clone(),
12475                prepared,
12476                "provider-neutral rows written into an embedded/file-backed SurrealDB SurrealKV store through the optional tsift-surrealdb adapter; warm-start reuses existing store when row hash matches",
12477                "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
12478                "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
12479            );
12480            backends.push(candidate_report);
12481            continue;
12482        }
12483        let started = Instant::now();
12484        let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
12485        let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12486        let rows = candidate_nodes + candidate_edges;
12487        let refresh = graph_db_backend_eval_refresh_operation(
12488            started.elapsed().as_micros(),
12489            rows,
12490            serde_json::json!({
12491                "nodes": candidate_nodes,
12492                "edges": candidate_edges,
12493            }),
12494        );
12495        let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12496        let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12497            candidate.name(),
12498            candidate.adapter_label(),
12499            true,
12500            root,
12501            path,
12502            scope,
12503            targets,
12504            depth,
12505            limit,
12506            impact_limit,
12507            &store,
12508            freshness,
12509            refresh.0,
12510            Some(refresh.1),
12511            Some(&sqlite_signatures),
12512            extra_warnings.clone(),
12513            prepared,
12514            candidate.projection_load(),
12515            candidate.lock_behavior(),
12516            candidate.install_portability(),
12517        );
12518        backends.push(candidate_report);
12519    }
12520
12521    Ok(GraphDbBackendEvalDataset {
12522        name: name.to_string(),
12523        target_count: targets.len(),
12524        nodes,
12525        edges,
12526        backends,
12527    })
12528}
12529
12530pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
12531    println!(
12532        "graph-db backend-eval baseline:{} candidates:{}",
12533        report.baseline_backend,
12534        report.candidates.join(", ")
12535    );
12536    for phase in &report.phase_timings {
12537        println!(
12538            "phase:{} {}us {}",
12539            phase.name, phase.duration_micros, phase.detail
12540        );
12541    }
12542    for dataset in &report.datasets {
12543        println!(
12544            "dataset:{} targets:{} rows:{}",
12545            dataset.name,
12546            dataset.target_count,
12547            dataset.nodes + dataset.edges
12548        );
12549        for backend in &dataset.backends {
12550            println!(
12551                "  backend:{} total:{}us parity:{}",
12552                backend.backend, backend.total_micros, backend.parity.matches_sqlite
12553            );
12554            println!("    projection-load: {}", backend.projection_load);
12555            println!("    lock-behavior: {}", backend.lock_behavior);
12556            println!("    install-portability: {}", backend.install_portability);
12557            for operation in &backend.operations {
12558                println!(
12559                    "    {} {} {}us",
12560                    operation.name, operation.status, operation.duration_micros
12561                );
12562            }
12563            for diagnostic in &backend.parity.diagnostics {
12564                println!("    parity: {diagnostic}");
12565            }
12566        }
12567    }
12568    for decision in &report.promotion {
12569        println!("promotion {}: {}", decision.backend, decision.decision);
12570        println!("  gate: {}", decision.gate.status);
12571        for reason in &decision.reasons {
12572            println!("  reason: {reason}");
12573        }
12574        for check in &decision.gate.required_checks {
12575            println!("  check: {check}");
12576        }
12577    }
12578    println!("metric-digest: {}", report.metric_digest_command);
12579    println!(
12580        "repeat-samples: {}",
12581        report.performance_gate.repeated_sample_command
12582    );
12583}
12584
12585fn traversal_expand_command(root: &Path, handle: &str) -> String {
12586    format!(
12587        "tsift traverse {} --path {} --depth 1 --limit 50",
12588        shell_quote(handle),
12589        shell_quote(root.to_string_lossy().as_ref())
12590    )
12591}
12592
12593fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
12594    let display = relativize(file, root);
12595    let handle = stable_handle("gfil", &format!("file:{display}"));
12596    TraversalNode {
12597        handle: handle.clone(),
12598        kind: "file".to_string(),
12599        label: display.clone(),
12600        ref_id: Some(display.clone()),
12601        path: Some(display),
12602        line: None,
12603        detail: None,
12604        properties: BTreeMap::new(),
12605        expand: traversal_expand_command(root, &handle),
12606    }
12607}
12608
12609fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
12610    let mut node = traversal_file_node(root, file);
12611    if let Some(path) = node.path.clone() {
12612        node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
12613        node.expand = source_read_command(root, &path, 1, 80);
12614    }
12615    node
12616}
12617
12618fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
12619    let file = relativize(&symbol.file, root);
12620    let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
12621    let handle = stable_handle("gsym", &key);
12622    TraversalNode {
12623        handle: handle.clone(),
12624        kind: "symbol".to_string(),
12625        label: symbol.name.clone(),
12626        ref_id: Some(symbol.name.clone()),
12627        path: Some(file),
12628        line: Some(symbol.line),
12629        detail: Some(format!("{} {}", symbol.language, symbol.kind)),
12630        properties: BTreeMap::new(),
12631        expand: traversal_expand_command(root, &handle),
12632    }
12633}
12634
12635fn traversal_ast_span_expand_command(
12636    root: &Path,
12637    file: &str,
12638    symbol: &index::StoredSymbol,
12639    span: &AstSpanPreview,
12640) -> String {
12641    if symbol.language == "markdown" {
12642        markdown_ast_command(root, file, Some(&span.handle))
12643    } else {
12644        let line_count = span
12645            .end_line
12646            .saturating_sub(span.start_line)
12647            .saturating_add(1)
12648            .max(1);
12649        source_read_command(root, file, span.start_line, line_count)
12650    }
12651}
12652
12653fn traversal_ast_span_node(
12654    root: &Path,
12655    symbol: &index::StoredSymbol,
12656    source: &[u8],
12657    symbols: &[&index::StoredSymbol],
12658) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12659    let span = stored_symbol_ast_span_in_file(symbol, source, symbols, usize::MAX)?;
12660    let file = relativize(&symbol.file, root);
12661    let mut properties = BTreeMap::new();
12662    properties.insert("layer".to_string(), "ast_navigation".to_string());
12663    properties.insert("language".to_string(), symbol.language.clone());
12664    properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12665    properties.insert("node_kind".to_string(), span.node_kind.clone());
12666    properties.insert("start_byte".to_string(), span.start_byte.to_string());
12667    properties.insert("end_byte".to_string(), span.end_byte.to_string());
12668    properties.insert("end_line".to_string(), span.end_line.to_string());
12669    if let Some(body_start_byte) = span.body_start_byte {
12670        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12671    }
12672    if let Some(body_end_byte) = span.body_end_byte {
12673        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12674    }
12675    if let Some(body_start_line) = span.body_start_line {
12676        properties.insert("body_start_line".to_string(), body_start_line.to_string());
12677    }
12678    if let Some(body_end_line) = span.body_end_line {
12679        properties.insert("body_end_line".to_string(), body_end_line.to_string());
12680    }
12681    if let Some(parent_handle) = &span.parent_handle {
12682        properties.insert("parent_handle".to_string(), parent_handle.clone());
12683    }
12684    if !span.child_handles.is_empty() {
12685        properties.insert("child_handles".to_string(), span.child_handles.join(","));
12686    }
12687    if let Some(parent_module) = &symbol.parent_module {
12688        properties.insert("parent_module".to_string(), parent_module.clone());
12689    }
12690    if let Some(markdown) = &span.markdown {
12691        properties.insert(
12692            "markdown_block_kind".to_string(),
12693            markdown_ast_block_kind(&symbol.kind),
12694        );
12695        if let Some(heading_level) = markdown.heading_level {
12696            properties.insert("heading_level".to_string(), heading_level.to_string());
12697        }
12698        if !markdown.section_path.is_empty() {
12699            properties.insert(
12700                "section_path".to_string(),
12701                markdown.section_path.join(" > "),
12702            );
12703        }
12704        if let Some(section_handle) = &markdown.section_handle {
12705            properties.insert("section_handle".to_string(), section_handle.clone());
12706        }
12707        if let Some(list_depth) = markdown.list_depth {
12708            properties.insert("list_depth".to_string(), list_depth.to_string());
12709        }
12710        if let Some(fence_language) = &markdown.fence_language {
12711            properties.insert("fence_language".to_string(), fence_language.clone());
12712        }
12713    }
12714
12715    let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12716    let node = TraversalNode {
12717        handle: span.handle.clone(),
12718        kind: "ast_span".to_string(),
12719        label: symbol.name.clone(),
12720        ref_id: Some(symbol.name.clone()),
12721        path: Some(file.clone()),
12722        line: Some(line),
12723        detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12724        properties,
12725        expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12726    };
12727    let entry = TraversalAstSpanIndexEntry {
12728        handle: span.handle,
12729        symbol_handle: String::new(),
12730        file_handle: None,
12731        file,
12732        name: symbol.name.clone(),
12733        kind: symbol.kind.clone(),
12734        language: symbol.language.clone(),
12735        node_kind: span.node_kind,
12736        start_byte: span.start_byte,
12737        end_byte: span.end_byte,
12738        parent_module: symbol.parent_module.clone(),
12739        markdown: span.markdown,
12740    };
12741    Some((node, entry))
12742}
12743
12744fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12745    let handle = stable_handle("gsym", &format!("symbol:{name}"));
12746    TraversalNode {
12747        handle: handle.clone(),
12748        kind: "symbol".to_string(),
12749        label: name.to_string(),
12750        ref_id: Some(name.to_string()),
12751        path: None,
12752        line: None,
12753        detail: Some("unresolved call target".to_string()),
12754        properties: BTreeMap::new(),
12755        expand: traversal_expand_command(root, &handle),
12756    }
12757}
12758
12759fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12760    let file = relativize(&route.file, root);
12761    let method = route.method.as_deref().unwrap_or("any");
12762    let key = format!(
12763        "route:{file}:{}:{}:{}",
12764        route.line, method, route.route_path
12765    );
12766    let handle = stable_handle("grte", &key);
12767    TraversalNode {
12768        handle: handle.clone(),
12769        kind: "route".to_string(),
12770        label: format!("{} {}", method.to_uppercase(), route.route_path),
12771        ref_id: Some(route.route_path.clone()),
12772        path: Some(file),
12773        line: Some(route.line),
12774        detail: Some(format!(
12775            "{} route handled by {}",
12776            route.framework, route.handler_name
12777        )),
12778        properties: BTreeMap::new(),
12779        expand: traversal_expand_command(root, &handle),
12780    }
12781}
12782
12783fn traversal_cargo_workspace_node(
12784    root: &Path,
12785    workspace: &multiplicity::CargoWorkspaceInfo,
12786) -> TraversalNode {
12787    let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12788        .to_string_lossy()
12789        .replace('\\', "/");
12790    let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12791        .to_string_lossy()
12792        .replace('\\', "/");
12793    let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12794    let mut properties = BTreeMap::new();
12795    properties.insert("layer".to_string(), "cargo_workspace".to_string());
12796    properties.insert("workspace_root".to_string(), workspace_root.clone());
12797    properties.insert("members".to_string(), workspace.members.join(","));
12798    properties.insert(
12799        "default_members".to_string(),
12800        workspace.default_members.join(","),
12801    );
12802    TraversalNode {
12803        handle: handle.clone(),
12804        kind: "cargo_workspace".to_string(),
12805        label: if workspace_root.is_empty() {
12806            "root cargo workspace".to_string()
12807        } else {
12808            workspace_root
12809        },
12810        ref_id: Some(workspace.id.clone()),
12811        path: Some(manifest),
12812        line: None,
12813        detail: Some("Cargo workspace manifest".to_string()),
12814        properties,
12815        expand: traversal_expand_command(root, &handle),
12816    }
12817}
12818
12819fn traversal_cargo_package_node(
12820    root: &Path,
12821    package: &multiplicity::CargoPackageInfo,
12822) -> TraversalNode {
12823    let manifest = relativize_pathbuf(&package.manifest_path, root)
12824        .to_string_lossy()
12825        .replace('\\', "/");
12826    let package_root = relativize_pathbuf(&package.package_root, root)
12827        .to_string_lossy()
12828        .replace('\\', "/");
12829    let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12830        .to_string_lossy()
12831        .replace('\\', "/");
12832    let handle = stable_handle(
12833        "gcpk",
12834        &format!("cargo-package:{manifest}:{}", package.name),
12835    );
12836    let mut properties = BTreeMap::new();
12837    properties.insert("layer".to_string(), "cargo_package".to_string());
12838    properties.insert("package_name".to_string(), package.name.clone());
12839    properties.insert(
12840        "normalized_name".to_string(),
12841        package.normalized_name.clone(),
12842    );
12843    properties.insert("package_root".to_string(), package_root.clone());
12844    properties.insert("workspace_root".to_string(), workspace_root);
12845    properties.insert("features".to_string(), package.features.join(","));
12846    properties.insert("targets".to_string(), package.targets.join(","));
12847    properties.insert(
12848        "dependencies".to_string(),
12849        package
12850            .dependencies
12851            .iter()
12852            .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12853            .collect::<Vec<_>>()
12854            .join(","),
12855    );
12856    TraversalNode {
12857        handle: handle.clone(),
12858        kind: "cargo_package".to_string(),
12859        label: package.name.clone(),
12860        ref_id: Some(package.scope_id.clone()),
12861        path: Some(manifest),
12862        line: None,
12863        detail: Some(format!(
12864            "Cargo package in {}",
12865            if package_root.is_empty() {
12866                "."
12867            } else {
12868                package_root.as_str()
12869            }
12870        )),
12871        properties,
12872        expand: traversal_expand_command(root, &handle),
12873    }
12874}
12875
12876fn traversal_session_node(
12877    root: &Path,
12878    markdown_path: &Path,
12879    session_id: Option<&str>,
12880) -> TraversalNode {
12881    let display = relativize_pathbuf(markdown_path, root)
12882        .to_string_lossy()
12883        .replace('\\', "/");
12884    let handle = stable_handle("gses", &format!("session:{display}"));
12885    TraversalNode {
12886        handle: handle.clone(),
12887        kind: "session".to_string(),
12888        label: session_id.unwrap_or(&display).to_string(),
12889        ref_id: session_id.map(str::to_string),
12890        path: Some(display),
12891        line: None,
12892        detail: Some("agent-doc session artifact".to_string()),
12893        properties: BTreeMap::new(),
12894        expand: traversal_expand_command(root, &handle),
12895    }
12896}
12897
12898fn traversal_backlog_node(
12899    root: &Path,
12900    markdown_path: &Path,
12901    id: &str,
12902    text: &str,
12903    line: i64,
12904) -> TraversalNode {
12905    let display = relativize_pathbuf(markdown_path, root)
12906        .to_string_lossy()
12907        .replace('\\', "/");
12908    let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12909    TraversalNode {
12910        handle: handle.clone(),
12911        kind: "backlog".to_string(),
12912        label: format!("#{id}"),
12913        ref_id: Some(id.to_string()),
12914        path: Some(display),
12915        line: Some(line),
12916        detail: Some(text.to_string()),
12917        properties: BTreeMap::new(),
12918        expand: traversal_expand_command(root, &handle),
12919    }
12920}
12921
12922fn traversal_job_packet_node(
12923    root: &Path,
12924    markdown_path: &Path,
12925    label: &str,
12926    ref_id: Option<&str>,
12927    detail: &str,
12928    line: i64,
12929) -> TraversalNode {
12930    let display = relativize_pathbuf(markdown_path, root)
12931        .to_string_lossy()
12932        .replace('\\', "/");
12933    let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12934    TraversalNode {
12935        handle: handle.clone(),
12936        kind: "job_packet".to_string(),
12937        label: label.to_string(),
12938        ref_id: ref_id.map(str::to_string),
12939        path: Some(display),
12940        line: Some(line),
12941        detail: Some(detail.to_string()),
12942        properties: BTreeMap::new(),
12943        expand: traversal_expand_command(root, &handle),
12944    }
12945}
12946
12947#[derive(Clone, Debug)]
12948struct ParsedWorkerResult {
12949    id: String,
12950    status: String,
12951    touched_files: Vec<String>,
12952    tests: Vec<String>,
12953    follow_up_ids: Vec<String>,
12954}
12955
12956fn traversal_worker_result_node(
12957    root: &Path,
12958    markdown_path: &Path,
12959    parsed: &ParsedWorkerResult,
12960    line_text: &str,
12961    line: i64,
12962) -> TraversalNode {
12963    let display = relativize_pathbuf(markdown_path, root)
12964        .to_string_lossy()
12965        .replace('\\', "/");
12966    let handle = stable_handle(
12967        "wres",
12968        &format!(
12969            "worker-result:{display}:{}:{}:{}",
12970            parsed.id, parsed.status, line
12971        ),
12972    );
12973    let mut properties = BTreeMap::new();
12974    properties.insert("status".to_string(), parsed.status.clone());
12975    if !parsed.touched_files.is_empty() {
12976        properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12977    }
12978    if !parsed.tests.is_empty() {
12979        properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12980    }
12981    if !parsed.follow_up_ids.is_empty() {
12982        properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12983    }
12984    TraversalNode {
12985        handle: handle.clone(),
12986        kind: "worker_result".to_string(),
12987        label: format!("{} #{}", parsed.status, parsed.id),
12988        ref_id: Some(parsed.id.clone()),
12989        path: Some(display),
12990        line: Some(line),
12991        detail: Some(line_text.trim().to_string()),
12992        properties,
12993        expand: traversal_expand_command(root, &handle),
12994    }
12995}
12996
12997fn traversal_tokens(input: &str) -> BTreeSet<String> {
12998    input
12999        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
13000        .flat_map(|part| part.split(['_', '-']))
13001        .map(str::trim)
13002        .filter(|part| part.len() >= 3)
13003        .map(|part| part.to_ascii_lowercase())
13004        .collect()
13005}
13006
13007fn traversal_ast_span_contains(
13008    parent: &TraversalAstSpanIndexEntry,
13009    child: &TraversalAstSpanIndexEntry,
13010) -> bool {
13011    parent.handle != child.handle
13012        && parent.file == child.file
13013        && parent.start_byte <= child.start_byte
13014        && parent.end_byte >= child.end_byte
13015}
13016
13017fn traversal_ast_parent_handle<'a>(
13018    entry: &TraversalAstSpanIndexEntry,
13019    entries: &'a [TraversalAstSpanIndexEntry],
13020) -> Option<&'a str> {
13021    entries
13022        .iter()
13023        .filter(|candidate| traversal_ast_span_contains(candidate, entry))
13024        .min_by_key(|candidate| {
13025            (
13026                candidate.end_byte.saturating_sub(candidate.start_byte),
13027                candidate.start_byte,
13028                candidate.end_byte,
13029                candidate.kind.as_str(),
13030                candidate.name.as_str(),
13031                candidate.node_kind.as_str(),
13032            )
13033        })
13034        .map(|candidate| candidate.handle.as_str())
13035}
13036
13037fn traversal_ast_enclosing_module_handle<'a>(
13038    entry: &TraversalAstSpanIndexEntry,
13039    entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
13040    parent_by_handle: &BTreeMap<String, String>,
13041) -> Option<&'a str> {
13042    let mut current = parent_by_handle.get(&entry.handle);
13043    while let Some(handle) = current {
13044        let Some(parent) = entries_by_handle.get(handle) else {
13045            break;
13046        };
13047        if matches!(parent.kind.as_str(), "module" | "mod")
13048            || entry
13049                .parent_module
13050                .as_deref()
13051                .is_some_and(|module| module == parent.name)
13052        {
13053            return Some(parent.handle.as_str());
13054        }
13055        current = parent_by_handle.get(&parent.handle);
13056    }
13057    None
13058}
13059
13060fn link_ast_navigation_edges(
13061    graph: &mut TraversalGraphBuild,
13062    entries: &[TraversalAstSpanIndexEntry],
13063) {
13064    let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
13065    let entries_by_handle = entries
13066        .iter()
13067        .map(|entry| (entry.handle.clone(), entry.clone()))
13068        .collect::<BTreeMap<_, _>>();
13069    let mut parent_by_handle = BTreeMap::<String, String>::new();
13070    let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
13071
13072    for entry in entries {
13073        entries_by_file
13074            .entry(entry.file.clone())
13075            .or_default()
13076            .push(entry.clone());
13077    }
13078
13079    for file_entries in entries_by_file.values() {
13080        for entry in file_entries {
13081            let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
13082            if let Some(parent) = &parent {
13083                parent_by_handle.insert(entry.handle.clone(), parent.clone());
13084            }
13085            let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
13086            children_by_parent
13087                .entry(sibling_key)
13088                .or_default()
13089                .push(entry.clone());
13090        }
13091    }
13092
13093    for entry in entries {
13094        let parent = parent_by_handle.get(&entry.handle);
13095        if let Some(parent) = parent {
13096            graph.add_edge(
13097                parent,
13098                &entry.handle,
13099                "contains",
13100                Some("AST parent contains child span".to_string()),
13101                1,
13102            );
13103            graph.add_edge(
13104                parent,
13105                &entry.handle,
13106                "child",
13107                Some("AST child span".to_string()),
13108                1,
13109            );
13110            graph.add_edge(
13111                &entry.handle,
13112                parent,
13113                "parent",
13114                Some("AST parent span".to_string()),
13115                1,
13116            );
13117        } else if let Some(file_handle) = &entry.file_handle {
13118            graph.add_edge(
13119                file_handle,
13120                &entry.handle,
13121                "contains",
13122                Some("file contains top-level AST span".to_string()),
13123                1,
13124            );
13125        }
13126
13127        if let Some(module_handle) =
13128            traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
13129        {
13130            graph.add_edge(
13131                &entry.handle,
13132                module_handle,
13133                "enclosing_module",
13134                Some("nearest enclosing module AST span".to_string()),
13135                1,
13136            );
13137        }
13138
13139        if entry.language == "markdown"
13140            && let Some(markdown) = &entry.markdown
13141            && let Some(section_handle) = &markdown.section_handle
13142            && section_handle != &entry.handle
13143        {
13144            graph.add_edge(
13145                section_handle,
13146                &entry.handle,
13147                "contains_markdown_block",
13148                Some("Markdown section contains block".to_string()),
13149                1,
13150            );
13151            graph.add_edge(
13152                &entry.handle,
13153                section_handle,
13154                "enclosing_section",
13155                Some("Markdown enclosing section".to_string()),
13156                1,
13157            );
13158        }
13159    }
13160
13161    for siblings in children_by_parent.values_mut() {
13162        siblings.sort_by(|left, right| {
13163            left.start_byte
13164                .cmp(&right.start_byte)
13165                .then(left.end_byte.cmp(&right.end_byte))
13166                .then(left.kind.cmp(&right.kind))
13167                .then(left.name.cmp(&right.name))
13168                .then(left.node_kind.cmp(&right.node_kind))
13169                .then(left.handle.cmp(&right.handle))
13170        });
13171        for pair in siblings.windows(2) {
13172            let previous = &pair[0];
13173            let next = &pair[1];
13174            graph.add_edge(
13175                &previous.handle,
13176                &next.handle,
13177                "next_sibling",
13178                Some("next AST sibling span".to_string()),
13179                1,
13180            );
13181            graph.add_edge(
13182                &next.handle,
13183                &previous.handle,
13184                "previous_sibling",
13185                Some("previous AST sibling span".to_string()),
13186                1,
13187            );
13188        }
13189    }
13190}
13191
13192fn traversal_markdown_embedded_symbol_node(
13193    root: &Path,
13194    entry: &TraversalAstSpanIndexEntry,
13195    markdown: &MarkdownSpanMetadata,
13196    embedded: &MarkdownEmbeddedSymbol,
13197) -> TraversalNode {
13198    let mut properties = BTreeMap::new();
13199    properties.insert("layer".to_string(), "embedded_code".to_string());
13200    properties.insert("embedded".to_string(), "true".to_string());
13201    properties.insert("language".to_string(), embedded.language.clone());
13202    properties.insert("symbol_kind".to_string(), embedded.kind.clone());
13203    properties.insert("node_kind".to_string(), embedded.node_kind.clone());
13204    properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
13205    properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
13206    properties.insert("end_line".to_string(), embedded.end_line.to_string());
13207    properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
13208    properties.insert(
13209        "markdown_block_kind".to_string(),
13210        markdown_ast_block_kind(&entry.kind),
13211    );
13212    if let Some(body_start_byte) = embedded.body_start_byte {
13213        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
13214    }
13215    if let Some(body_end_byte) = embedded.body_end_byte {
13216        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
13217    }
13218    if let Some(body_start_line) = embedded.body_start_line {
13219        properties.insert("body_start_line".to_string(), body_start_line.to_string());
13220    }
13221    if let Some(body_end_line) = embedded.body_end_line {
13222        properties.insert("body_end_line".to_string(), body_end_line.to_string());
13223    }
13224    if let Some(fence_language) = &markdown.fence_language {
13225        properties.insert("fence_language".to_string(), fence_language.clone());
13226    }
13227    if !markdown.section_path.is_empty() {
13228        properties.insert(
13229            "section_path".to_string(),
13230            markdown.section_path.join(" > "),
13231        );
13232    }
13233    if let Some(section_handle) = &markdown.section_handle {
13234        properties.insert("section_handle".to_string(), section_handle.clone());
13235    }
13236    let line_count = embedded
13237        .end_line
13238        .saturating_sub(embedded.start_line)
13239        .saturating_add(1)
13240        .max(1);
13241    TraversalNode {
13242        handle: embedded.handle.clone(),
13243        kind: "ast_span".to_string(),
13244        label: embedded.name.clone(),
13245        ref_id: Some(embedded.name.clone()),
13246        path: Some(entry.file.clone()),
13247        line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
13248        detail: Some(format!(
13249            "{} {} embedded in Markdown fence",
13250            embedded.language, embedded.kind
13251        )),
13252        properties,
13253        expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
13254    }
13255}
13256
13257fn link_markdown_embedded_code_edges(
13258    graph: &mut TraversalGraphBuild,
13259    root: &Path,
13260    entries: &[TraversalAstSpanIndexEntry],
13261) {
13262    for entry in entries {
13263        let Some(markdown) = &entry.markdown else {
13264            continue;
13265        };
13266        for embedded in &markdown.embedded_symbols {
13267            let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
13268            graph.add_node(node);
13269            graph.add_edge(
13270                &entry.handle,
13271                &embedded.handle,
13272                "contains",
13273                Some("Markdown fence contains embedded AST symbol".to_string()),
13274                1,
13275            );
13276            graph.add_edge(
13277                &entry.handle,
13278                &embedded.handle,
13279                "child",
13280                Some("embedded code symbol".to_string()),
13281                1,
13282            );
13283            graph.add_edge(
13284                &entry.handle,
13285                &embedded.handle,
13286                "contains_embedded_symbol",
13287                Some("Markdown fence contains embedded code symbol".to_string()),
13288                1,
13289            );
13290            graph.add_edge(
13291                &embedded.handle,
13292                &entry.handle,
13293                "parent",
13294                Some("Markdown fence parent span".to_string()),
13295                1,
13296            );
13297            graph.add_edge(
13298                &embedded.handle,
13299                &entry.handle,
13300                "embedded_in_fence",
13301                Some("embedded code symbol belongs to Markdown fence".to_string()),
13302                1,
13303            );
13304            if let Some(section_handle) = &markdown.section_handle
13305                && section_handle != &entry.handle
13306            {
13307                graph.add_edge(
13308                    section_handle,
13309                    &embedded.handle,
13310                    "contains_embedded_code",
13311                    Some("Markdown section contains embedded code symbol".to_string()),
13312                    1,
13313                );
13314                graph.add_edge(
13315                    &embedded.handle,
13316                    section_handle,
13317                    "enclosing_section",
13318                    Some("Markdown enclosing section".to_string()),
13319                    1,
13320                );
13321            }
13322        }
13323    }
13324}
13325
13326fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
13327    let mut tokens = traversal_tokens(&node.label);
13328    if let Some(ref_id) = &node.ref_id {
13329        tokens.extend(traversal_tokens(ref_id));
13330    }
13331    if let Some(path) = &node.path {
13332        tokens.extend(traversal_tokens(path));
13333    }
13334    if let Some(detail) = &node.detail {
13335        tokens.extend(traversal_tokens(detail));
13336    }
13337    tokens
13338}
13339
13340fn markdown_code_spans(input: &str) -> Vec<String> {
13341    input
13342        .split('`')
13343        .enumerate()
13344        .filter(|(idx, _)| idx % 2 == 1)
13345        .map(|(_, part)| part.trim().to_string())
13346        .filter(|part| !part.is_empty())
13347        .collect()
13348}
13349
13350fn push_traversal_token_index(
13351    index: &mut HashMap<String, Vec<usize>>,
13352    tokens: &BTreeSet<String>,
13353    entry_index: usize,
13354) {
13355    for token in tokens {
13356        index.entry(token.clone()).or_default().push(entry_index);
13357    }
13358}
13359
13360impl<'a> TraversalCodeLookup<'a> {
13361    fn new(
13362        symbols: &'a [TraversalSymbolIndexEntry],
13363        files: &'a [TraversalFileIndexEntry],
13364        routes: &'a [TraversalRouteIndexEntry],
13365        multiplicities: &'a [TraversalMultiplicityIndexEntry],
13366    ) -> Self {
13367        let mut symbol_index = HashMap::new();
13368        for (idx, entry) in symbols.iter().enumerate() {
13369            push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
13370        }
13371        let mut file_index = HashMap::new();
13372        let mut file_path_index = HashMap::new();
13373        for (idx, entry) in files.iter().enumerate() {
13374            push_traversal_token_index(&mut file_index, &entry.tokens, idx);
13375            if let Some(path) = entry.node.path.as_ref() {
13376                file_path_index.insert(path.clone(), path.clone());
13377            }
13378        }
13379        let mut route_index = HashMap::new();
13380        for (idx, entry) in routes.iter().enumerate() {
13381            push_traversal_token_index(&mut route_index, &entry.tokens, idx);
13382        }
13383        let mut multiplicity_index = HashMap::new();
13384        for (idx, entry) in multiplicities.iter().enumerate() {
13385            push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
13386        }
13387        Self {
13388            symbols,
13389            files,
13390            routes,
13391            multiplicities,
13392            symbol_index,
13393            file_index,
13394            route_index,
13395            multiplicity_index,
13396            file_path_index,
13397        }
13398    }
13399
13400    fn touched_files_for_line(&self, line: &str) -> Vec<String> {
13401        let mut touched_files = BTreeSet::new();
13402        for candidate in markdown_code_spans(line)
13403            .into_iter()
13404            .chain(line.split_whitespace().map(str::to_string))
13405        {
13406            for path in traversal_path_candidates(&candidate) {
13407                if let Some(file) = self.file_path_index.get(&path) {
13408                    touched_files.insert(file.clone());
13409                }
13410            }
13411        }
13412        touched_files.into_iter().collect()
13413    }
13414}
13415
13416fn traversal_path_candidates(candidate: &str) -> Vec<String> {
13417    let trimmed = candidate.trim_matches(|ch: char| {
13418        matches!(
13419            ch,
13420            '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
13421        )
13422    });
13423    if trimmed.is_empty() {
13424        return Vec::new();
13425    }
13426    let mut candidates = vec![trimmed.to_string()];
13427    if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
13428        && !path.is_empty()
13429        && line_suffix.chars().all(|ch| ch.is_ascii_digit())
13430    {
13431        candidates.push(path.to_string());
13432    }
13433    candidates
13434}
13435
13436fn parse_worker_result_line(
13437    line: &str,
13438    lookup: &TraversalCodeLookup<'_>,
13439) -> Vec<ParsedWorkerResult> {
13440    if line.trim_start().starts_with("- [") {
13441        return Vec::new();
13442    }
13443    let lower = line.to_ascii_lowercase();
13444    let status =
13445        if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
13446        {
13447            "completed"
13448        } else if lower.contains("blocked") || lower.contains("externally blocked") {
13449            "blocked"
13450        } else {
13451            return Vec::new();
13452        };
13453    let result_prefix_end = ["follow-up", "follow up", "next:"]
13454        .iter()
13455        .filter_map(|marker| lower.find(marker))
13456        .min()
13457        .unwrap_or(line.len());
13458    let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
13459    if ids.is_empty() {
13460        return Vec::new();
13461    }
13462    let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
13463    let all_ids = extract_conflict_target_refs(line);
13464
13465    let touched_files = lookup.touched_files_for_line(line);
13466    let tests = markdown_code_spans(line)
13467        .into_iter()
13468        .filter(|span| span.to_ascii_lowercase().contains("test"))
13469        .collect::<Vec<_>>();
13470
13471    ids.iter()
13472        .map(|id| ParsedWorkerResult {
13473            id: id.clone(),
13474            status: status.to_string(),
13475            touched_files: touched_files.clone(),
13476            tests: tests.clone(),
13477            follow_up_ids: all_ids
13478                .iter()
13479                .filter(|other| *other != id && !result_ids.contains(*other))
13480                .cloned()
13481                .collect(),
13482        })
13483        .collect()
13484}
13485
13486fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
13487    let hinted_path = if path_hint.is_absolute() {
13488        path_hint.to_path_buf()
13489    } else {
13490        root.join(path_hint)
13491    };
13492    if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
13493        return Some(hinted_path);
13494    }
13495    None
13496}
13497
13498fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
13499    let candidate = if path.is_absolute() {
13500        path.to_path_buf()
13501    } else {
13502        source_root.join(path)
13503    };
13504    if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
13505        return false;
13506    }
13507    if !matches!(
13508        candidate.extension().and_then(|ext| ext.to_str()),
13509        Some("md" | "mdx")
13510    ) {
13511        return false;
13512    }
13513    fs::read_to_string(&candidate)
13514        .map(|content| session_markdown::markdown_content_looks_like_agent_doc_session(&content))
13515        .unwrap_or(false)
13516}
13517
13518fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
13519    if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
13520        return Ok(vec![hinted_path]);
13521    }
13522    let mut files = Vec::new();
13523    let walker = ignore::WalkBuilder::new(root)
13524        .hidden(true)
13525        .git_ignore(true)
13526        .git_global(true)
13527        .git_exclude(true)
13528        .build();
13529    for result in walker {
13530        let entry =
13531            result.with_context(|| format!("walking markdown files under {}", root.display()))?;
13532        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
13533            continue;
13534        }
13535        if traversal_path_is_generated_artifact(root, root, entry.path()) {
13536            continue;
13537        }
13538        if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
13539            files.push(entry.path().to_path_buf());
13540        }
13541    }
13542    files.sort();
13543    Ok(files)
13544}
13545
13546fn traversal_watermark_path(root: &Path, path: &Path) -> String {
13547    path.strip_prefix(root)
13548        .unwrap_or(path)
13549        .to_string_lossy()
13550        .replace('\\', "/")
13551}
13552
13553fn push_traversal_metadata_watermark_part(
13554    root: &Path,
13555    path: &Path,
13556    label: &str,
13557    parts: &mut Vec<String>,
13558) {
13559    let display = traversal_watermark_path(root, path);
13560    match fs::metadata(path) {
13561        Ok(metadata) => {
13562            let (secs, nanos) = metadata
13563                .modified()
13564                .ok()
13565                .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
13566                .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
13567                .unwrap_or((0, 0));
13568            parts.push(format!(
13569                "{label}:{display}:len={}:mtime={secs}.{nanos}",
13570                metadata.len()
13571            ));
13572        }
13573        Err(_) => parts.push(format!("{label}:{display}:missing")),
13574    }
13575}
13576
13577#[derive(Serialize)]
13578struct TraversalSummaryWatermarkRow<'a> {
13579    symbol_name: &'a str,
13580    file_path: &'a str,
13581    entities: &'a Option<Vec<summarize::Entity>>,
13582    relationships: &'a Option<Vec<summarize::Relationship>>,
13583    concept_labels: &'a Option<Vec<String>>,
13584}
13585
13586fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
13587    let summaries_db = root.join(".tsift/summaries.db");
13588    if !summaries_db.exists() {
13589        parts.push("summaries_db:absent".to_string());
13590        return Ok(());
13591    }
13592
13593    match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
13594        .and_then(|summary_db| summary_db.all())
13595    {
13596        Ok(summaries) => {
13597            let rows = summaries
13598                .iter()
13599                .map(|summary| TraversalSummaryWatermarkRow {
13600                    symbol_name: &summary.symbol_name,
13601                    file_path: &summary.file_path,
13602                    entities: &summary.entities,
13603                    relationships: &summary.relationships,
13604                    concept_labels: &summary.concept_labels,
13605                })
13606                .collect::<Vec<_>>();
13607            parts.push(format!(
13608                "summaries_db:rows={}:semantic_hash={}",
13609                rows.len(),
13610                content_hash(&rows)?
13611            ));
13612        }
13613        Err(_) => {
13614            push_traversal_metadata_watermark_part(
13615                root,
13616                &summaries_db,
13617                "summaries_db_unreadable",
13618                parts,
13619            );
13620        }
13621    }
13622    Ok(())
13623}
13624
13625#[cfg(test)]
13626fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
13627    resolution::relative_path_is_generated_artifact(relative)
13628}
13629
13630fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
13631    resolution::path_is_generated_artifact(root, source_root, path)
13632}
13633
13634fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
13635    resolution::index_snapshot_part_is_generated(root, source_root, part)
13636}
13637
13638pub(crate) fn traversal_source_watermark(
13639    root: &Path,
13640    path_hint: &Path,
13641    scope: Option<&str>,
13642    session_only: bool,
13643) -> Result<Option<String>> {
13644    let mut parts = vec![
13645        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
13646        format!("scope:{}", scope.unwrap_or("root")),
13647        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
13648        format!("session_only:{session_only}"),
13649    ];
13650
13651    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13652        let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13653            Ok(targets) => targets,
13654            Err(_) => return Ok(None),
13655        };
13656        let Some(target) = targets.into_iter().next() else {
13657            return Ok(None);
13658        };
13659        let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13660            Ok(db) => db,
13661            Err(_) => return Ok(None),
13662        };
13663        parts.push(format!("index_label:{}", target.label));
13664        parts.push(format!(
13665            "index_scope:{}",
13666            target.scope_name.as_deref().unwrap_or("root")
13667        ));
13668        parts.push(format!(
13669            "index_source_root:{}",
13670            traversal_watermark_path(root, &target.source_root)
13671        ));
13672        let mut snapshot_rows = 0usize;
13673        for part in db.source_snapshot_parts()? {
13674            if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13675                continue;
13676            }
13677            snapshot_rows += 1;
13678            parts.push(format!("index_snapshot:{part}"));
13679        }
13680        parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13681    }
13682
13683    let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13684    parts.push(format!("markdown_count:{}", markdown_files.len()));
13685    for markdown_path in markdown_files {
13686        push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13687    }
13688
13689    push_traversal_summaries_watermark_part(root, &mut parts)?;
13690
13691    Ok(Some(content_hash(&parts)?))
13692}
13693
13694fn ranked_symbol_matches<'a>(
13695    query_tokens: &BTreeSet<String>,
13696    entries: &'a [TraversalSymbolIndexEntry],
13697    index: &HashMap<String, Vec<usize>>,
13698) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13699    let mut scores = BTreeMap::<usize, usize>::new();
13700    for token in query_tokens {
13701        if let Some(indices) = index.get(token) {
13702            for idx in indices {
13703                *scores.entry(*idx).or_default() += 1;
13704            }
13705        }
13706    }
13707    let mut matches = scores
13708        .into_iter()
13709        .map(|(idx, score)| (score, &entries[idx]))
13710        .collect::<Vec<_>>();
13711    matches.sort_by(|(left_score, left), (right_score, right)| {
13712        right_score
13713            .cmp(left_score)
13714            .then_with(|| left.node.label.cmp(&right.node.label))
13715            .then_with(|| left.handle.cmp(&right.handle))
13716    });
13717    matches
13718}
13719
13720fn ranked_file_matches<'a>(
13721    query_tokens: &BTreeSet<String>,
13722    entries: &'a [TraversalFileIndexEntry],
13723    index: &HashMap<String, Vec<usize>>,
13724) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13725    let mut scores = BTreeMap::<usize, usize>::new();
13726    for token in query_tokens {
13727        if let Some(indices) = index.get(token) {
13728            for idx in indices {
13729                *scores.entry(*idx).or_default() += 1;
13730            }
13731        }
13732    }
13733    let mut matches = scores
13734        .into_iter()
13735        .map(|(idx, score)| (score, &entries[idx]))
13736        .collect::<Vec<_>>();
13737    matches.sort_by(|(left_score, left), (right_score, right)| {
13738        right_score
13739            .cmp(left_score)
13740            .then_with(|| left.node.label.cmp(&right.node.label))
13741            .then_with(|| left.handle.cmp(&right.handle))
13742    });
13743    matches
13744}
13745
13746fn ranked_route_matches<'a>(
13747    query_tokens: &BTreeSet<String>,
13748    entries: &'a [TraversalRouteIndexEntry],
13749    index: &HashMap<String, Vec<usize>>,
13750) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13751    let mut scores = BTreeMap::<usize, usize>::new();
13752    for token in query_tokens {
13753        if let Some(indices) = index.get(token) {
13754            for idx in indices {
13755                *scores.entry(*idx).or_default() += 1;
13756            }
13757        }
13758    }
13759    let mut matches = scores
13760        .into_iter()
13761        .map(|(idx, score)| (score, &entries[idx]))
13762        .collect::<Vec<_>>();
13763    matches.sort_by(|(left_score, left), (right_score, right)| {
13764        right_score
13765            .cmp(left_score)
13766            .then_with(|| left.node.label.cmp(&right.node.label))
13767            .then_with(|| left.handle.cmp(&right.handle))
13768    });
13769    matches
13770}
13771
13772fn ranked_multiplicity_matches<'a>(
13773    query_tokens: &BTreeSet<String>,
13774    entries: &'a [TraversalMultiplicityIndexEntry],
13775    index: &HashMap<String, Vec<usize>>,
13776) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13777    let mut scores = BTreeMap::<usize, usize>::new();
13778    for token in query_tokens {
13779        if let Some(indices) = index.get(token) {
13780            for idx in indices {
13781                *scores.entry(*idx).or_default() += 1;
13782            }
13783        }
13784    }
13785    let mut matches = scores
13786        .into_iter()
13787        .map(|(idx, score)| (score, &entries[idx]))
13788        .collect::<Vec<_>>();
13789    matches.sort_by(|(left_score, left), (right_score, right)| {
13790        right_score
13791            .cmp(left_score)
13792            .then_with(|| left.node.kind.cmp(&right.node.kind))
13793            .then_with(|| left.node.label.cmp(&right.node.label))
13794            .then_with(|| left.handle.cmp(&right.handle))
13795    });
13796    matches
13797}
13798
13799fn link_backlog_to_code_nodes(
13800    graph: &mut TraversalGraphBuild,
13801    backlog: &TraversalNode,
13802    text: &str,
13803    lookup: &TraversalCodeLookup<'_>,
13804    limit: usize,
13805) {
13806    let mut query_tokens = traversal_tokens(text);
13807    if let Some(ref_id) = &backlog.ref_id {
13808        query_tokens.extend(traversal_tokens(ref_id));
13809    }
13810    if query_tokens.is_empty() {
13811        return;
13812    }
13813
13814    for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13815        .into_iter()
13816        .take(limit)
13817    {
13818        graph.add_edge(
13819            &backlog.handle,
13820            &entry.handle,
13821            "mentions",
13822            Some("backlog text matches symbol tokens".to_string()),
13823            score,
13824        );
13825    }
13826
13827    for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13828        .into_iter()
13829        .take(limit.min(5))
13830    {
13831        graph.add_edge(
13832            &backlog.handle,
13833            &entry.handle,
13834            "mentions",
13835            Some("backlog text matches file tokens".to_string()),
13836            score,
13837        );
13838    }
13839
13840    for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13841        .into_iter()
13842        .take(limit.min(5))
13843    {
13844        graph.add_edge(
13845            &backlog.handle,
13846            &entry.handle,
13847            "mentions",
13848            Some("backlog text matches route tokens".to_string()),
13849            score,
13850        );
13851    }
13852
13853    for (score, entry) in ranked_multiplicity_matches(
13854        &query_tokens,
13855        lookup.multiplicities,
13856        &lookup.multiplicity_index,
13857    )
13858    .into_iter()
13859    .take(limit.min(5))
13860    {
13861        graph.add_edge(
13862            &backlog.handle,
13863            &entry.handle,
13864            "mentions",
13865            Some("backlog text matches multiplicity tokens".to_string()),
13866            score,
13867        );
13868    }
13869}
13870
13871fn load_agent_doc_traversal_nodes(
13872    root: &Path,
13873    path_hint: &Path,
13874    graph: &mut TraversalGraphBuild,
13875    lookup: &TraversalCodeLookup<'_>,
13876) -> Result<()> {
13877    for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13878        let content = match fs::read_to_string(&markdown_path) {
13879            Ok(content) => content,
13880            Err(err) => {
13881                graph.warnings.push(format!(
13882                    "session artifact unavailable: {}: {err}",
13883                    markdown_path.display()
13884                ));
13885                continue;
13886            }
13887        };
13888        let Some(document) = AgentDocSessionDocument::parse_if_session(&content) else {
13889            continue;
13890        };
13891
13892        let session = traversal_session_node(root, &markdown_path, document.session_id.as_deref());
13893        graph.add_node(session.clone());
13894        let lines = content.lines().collect::<Vec<_>>();
13895        let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13896        for item in &document.backlog_items {
13897            let backlog = traversal_backlog_node(
13898                root,
13899                &markdown_path,
13900                &item.id,
13901                &item.text,
13902                item.line as i64,
13903            );
13904            graph.add_node(backlog.clone());
13905            backlog_by_id.insert(item.id.clone(), backlog.clone());
13906            graph.add_edge(
13907                &session.handle,
13908                &backlog.handle,
13909                "contains",
13910                Some("session backlog item".to_string()),
13911                1,
13912            );
13913            link_backlog_to_code_nodes(graph, &backlog, &item.text, lookup, 8);
13914        }
13915
13916        let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13917        for item in &document.queue_items {
13918            match item {
13919                AgentDocQueueItem::Dispatch { value, line }
13920                | AgentDocQueueItem::Preset { value, line } => {
13921                    let dispatch_ref = value.strip_prefix('#').unwrap_or(value.as_str());
13922                    let node = traversal_job_packet_node(
13923                        root,
13924                        &markdown_path,
13925                        &format!("dispatch {value}"),
13926                        Some(dispatch_ref),
13927                        "agent-doc dispatch preset",
13928                        *line as i64,
13929                    );
13930                    graph.add_node(node.clone());
13931                    graph.add_edge(
13932                        &session.handle,
13933                        &node.handle,
13934                        "contains",
13935                        Some("session queued dispatch".to_string()),
13936                        1,
13937                    );
13938                }
13939                AgentDocQueueItem::Do { id, line } => {
13940                    let detail = backlog_by_id
13941                        .get(id)
13942                        .and_then(|node| node.detail.clone())
13943                        .unwrap_or_else(|| "queued backlog item".to_string());
13944                    let node = traversal_job_packet_node(
13945                        root,
13946                        &markdown_path,
13947                        &format!("do #{id}"),
13948                        Some(id),
13949                        &detail,
13950                        *line as i64,
13951                    );
13952                    graph.add_node(node.clone());
13953                    graph.add_edge(
13954                        &session.handle,
13955                        &node.handle,
13956                        "contains",
13957                        Some("session queued job packet".to_string()),
13958                        1,
13959                    );
13960                    if let Some(backlog) = backlog_by_id.get(id) {
13961                        graph.add_edge(
13962                            &node.handle,
13963                            &backlog.handle,
13964                            "targets",
13965                            Some("queued backlog item".to_string()),
13966                            1,
13967                        );
13968                    }
13969                    job_by_id.insert(id.clone(), node);
13970                }
13971            }
13972        }
13973
13974        let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13975        for (idx, line) in lines.iter().enumerate() {
13976            for parsed in parse_worker_result_line(line, lookup) {
13977                let line_no = idx as i64 + 1;
13978                if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13979                    continue;
13980                }
13981                let result =
13982                    traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13983                graph.add_node(result.clone());
13984                graph.add_edge(
13985                    &session.handle,
13986                    &result.handle,
13987                    "contains",
13988                    Some("session worker result".to_string()),
13989                    1,
13990                );
13991                if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13992                    graph.add_edge(
13993                        &backlog.handle,
13994                        &result.handle,
13995                        "has_result",
13996                        Some(format!("worker result {}", parsed.status)),
13997                        1,
13998                    );
13999                }
14000                if let Some(job) = job_by_id.get(&parsed.id) {
14001                    graph.add_edge(
14002                        &job.handle,
14003                        &result.handle,
14004                        "has_result",
14005                        Some(format!("queued worker result {}", parsed.status)),
14006                        1,
14007                    );
14008                }
14009                let mut result_text = line.to_string();
14010                if !parsed.touched_files.is_empty() {
14011                    result_text.push(' ');
14012                    result_text.push_str(&parsed.touched_files.join(" "));
14013                }
14014                link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
14015            }
14016        }
14017    }
14018    Ok(())
14019}
14020
14021#[derive(Debug, Clone)]
14022struct AgentDocIndexGate {
14023    db_path: Option<PathBuf>,
14024    source_root: PathBuf,
14025    diagnostics: Vec<String>,
14026}
14027
14028#[derive(Clone, Hash, PartialEq, Eq)]
14029struct AgentDocIndexGateCacheKey {
14030    root: PathBuf,
14031    path_hint: PathBuf,
14032    scope: Option<String>,
14033    packet_label: String,
14034}
14035
14036fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
14037    std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
14038> {
14039    static CACHE: std::sync::OnceLock<
14040        std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
14041    > = std::sync::OnceLock::new();
14042    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
14043}
14044
14045fn prepare_agent_doc_index_gate_cached(
14046    root: &Path,
14047    path_hint: &Path,
14048    scope: Option<&str>,
14049    packet_label: &str,
14050) -> (AgentDocIndexGate, String) {
14051    let key = AgentDocIndexGateCacheKey {
14052        root: root.to_path_buf(),
14053        path_hint: path_hint.to_path_buf(),
14054        scope: scope.map(str::to_string),
14055        packet_label: packet_label.to_string(),
14056    };
14057    if let Ok(cache) = agent_doc_index_gate_cache().lock()
14058        && let Some(cached) = cache.get(&key)
14059    {
14060        return (
14061            cached.clone(),
14062            "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
14063        );
14064    }
14065    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
14066    if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
14067        cache.insert(key, gate.clone());
14068    }
14069    (
14070        gate,
14071        "fresh inspection/refresh — cache miss on this preparation key".to_string(),
14072    )
14073}
14074
14075fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
14076    match state {
14077        SearchIndexState::Fresh => None,
14078        SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
14079        SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
14080    }
14081}
14082
14083fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
14084    rebuild_search_target_detail(&RebuildSearchTarget {
14085        label: target.label.clone(),
14086        reason,
14087        reindex_cmd: target.reindex_cmd.clone(),
14088    })
14089}
14090
14091fn index_refresh_diagnostic(
14092    target: &SearchIndexTarget,
14093    reason: RebuildSearchReason,
14094    summary: &index::IndexSummary,
14095    packet_label: &str,
14096) -> String {
14097    let changed = summary.new + summary.modified + summary.deleted;
14098    format!(
14099        "index refreshed: {}; updated {} changed file{} before {}",
14100        index_reason_detail(target, reason),
14101        changed,
14102        if changed == 1 { "" } else { "s" },
14103        packet_label
14104    )
14105}
14106
14107fn index_refresh_fallback_diagnostic(
14108    target: &SearchIndexTarget,
14109    reason: RebuildSearchReason,
14110    err: &anyhow::Error,
14111    packet_label: &str,
14112) -> String {
14113    format!(
14114        "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
14115        index_reason_detail(target, reason),
14116        packet_label
14117    )
14118}
14119
14120fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
14121    if let Some(scope_name) = scope
14122        && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
14123    {
14124        return scope.source_root;
14125    }
14126    if let Some(scope_name) = scope
14127        && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
14128    {
14129        return package.package_root;
14130    }
14131    if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
14132        return scope.source_root;
14133    }
14134    if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
14135        return package.package_root;
14136    }
14137    if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
14138        return scope.source_root;
14139    }
14140    root.to_path_buf()
14141}
14142
14143fn prepare_agent_doc_index_gate(
14144    root: &Path,
14145    path_hint: &Path,
14146    scope: Option<&str>,
14147    packet_label: &str,
14148) -> AgentDocIndexGate {
14149    let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
14150    let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
14151        Ok(targets) => targets,
14152        Err(err) => {
14153            return AgentDocIndexGate {
14154                db_path: None,
14155                source_root: fallback_source_root,
14156                diagnostics: vec![format!(
14157                    "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14158                )],
14159            };
14160        }
14161    };
14162    let Some(target) = targets.into_iter().next() else {
14163        return AgentDocIndexGate {
14164            db_path: None,
14165            source_root: fallback_source_root,
14166            diagnostics: vec![format!(
14167                "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
14168            )],
14169        };
14170    };
14171
14172    let state = match inspect_search_index(&target) {
14173        Ok(state) => state,
14174        Err(err) => {
14175            return AgentDocIndexGate {
14176                db_path: None,
14177                source_root: target.source_root,
14178                diagnostics: vec![format!(
14179                    "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14180                )],
14181            };
14182        }
14183    };
14184
14185    let Some(reason) = index_reason_for_state(state) else {
14186        return AgentDocIndexGate {
14187            db_path: Some(target.db_path),
14188            source_root: target.source_root,
14189            diagnostics: Vec::new(),
14190        };
14191    };
14192
14193    match apply_search_index_update(root, &target) {
14194        Ok(summary) => {
14195            // #gdbgatecold: the index was just rewritten, so any cached
14196            // pre-refresh inspection result for this scope (held by the
14197            // active lazily-backed `InspectScopeGuard`) is stale. Invalidate
14198            // the scope epoch so the next `inspect_read_only` re-reads the
14199            // fresh index.
14200            index::inspect_scope_invalidate_all();
14201            let diagnostics = vec![index_refresh_diagnostic(
14202                &target,
14203                reason,
14204                &summary,
14205                packet_label,
14206            )];
14207            AgentDocIndexGate {
14208                db_path: Some(target.db_path),
14209                source_root: target.source_root,
14210                diagnostics,
14211            }
14212        }
14213        Err(err) => {
14214            let diagnostics = vec![index_refresh_fallback_diagnostic(
14215                &target,
14216                reason,
14217                &err,
14218                packet_label,
14219            )];
14220            AgentDocIndexGate {
14221                db_path: None,
14222                source_root: target.source_root,
14223                diagnostics,
14224            }
14225        }
14226    }
14227}
14228
14229fn add_raw_source_file_nodes(
14230    root: &Path,
14231    source_root: &Path,
14232    graph: &mut TraversalGraphBuild,
14233    file_entries: &mut Vec<TraversalFileIndexEntry>,
14234) -> Result<()> {
14235    let mut entries = walk::walk_files(source_root)?;
14236    entries.sort_by(|left, right| left.path.cmp(&right.path));
14237    for entry in entries {
14238        let file = entry.path.to_string_lossy();
14239        let node = traversal_raw_source_file_node(root, file.as_ref());
14240        let entry = TraversalFileIndexEntry {
14241            handle: node.handle.clone(),
14242            tokens: traversal_node_tokens(&node),
14243            node: node.clone(),
14244        };
14245        graph.add_node(node);
14246        file_entries.push(entry);
14247    }
14248    Ok(())
14249}
14250
14251fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
14252    if scope_root.is_empty() {
14253        return true;
14254    }
14255    path == scope_root || path.starts_with(&format!("{scope_root}/"))
14256}
14257
14258fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
14259    let path = Path::new(file);
14260    if path.is_absolute() {
14261        return path.to_path_buf();
14262    }
14263    let source_candidate = source_root.join(path);
14264    if source_candidate.exists() {
14265        source_candidate
14266    } else {
14267        root.join(path)
14268    }
14269}
14270
14271fn cargo_import_alias_from_line(line: &str) -> Option<String> {
14272    let trimmed = line.trim();
14273    let rest = trimmed
14274        .strip_prefix("pub use ")
14275        .or_else(|| trimmed.strip_prefix("use "))
14276        .or_else(|| trimmed.strip_prefix("extern crate "))?;
14277    let alias = rest
14278        .split([':', ';', ' ', '\t'])
14279        .next()
14280        .unwrap_or_default()
14281        .trim();
14282    (!alias.is_empty()).then(|| alias.to_string())
14283}
14284
14285fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
14286    let mut aliases = BTreeSet::new();
14287    for entry in walk::walk_files(&package.package_root)? {
14288        if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
14289            continue;
14290        }
14291        let content = fs::read_to_string(&entry.path)
14292            .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
14293        aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
14294    }
14295    Ok(aliases)
14296}
14297
14298fn load_multiplicity_traversal_nodes(
14299    root: &Path,
14300    source_root: &Path,
14301    graph: &mut TraversalGraphBuild,
14302    file_handle_by_path: &HashMap<String, String>,
14303    multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
14304) -> Result<()> {
14305    let inventory = multiplicity::discover_cargo_inventory(source_root)?;
14306    let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
14307    for workspace in &inventory.workspaces {
14308        let node = traversal_cargo_workspace_node(root, workspace);
14309        workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
14310        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14311            handle: node.handle.clone(),
14312            tokens: traversal_node_tokens(&node),
14313            node: node.clone(),
14314        });
14315        graph.add_node(node);
14316    }
14317
14318    let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
14319    let mut package_nodes = Vec::new();
14320    for package in &inventory.packages {
14321        let node = traversal_cargo_package_node(root, package);
14322        package_handle_by_name
14323            .entry(package.name.clone())
14324            .or_default()
14325            .push(node.handle.clone());
14326        package_handle_by_name
14327            .entry(package.normalized_name.clone())
14328            .or_default()
14329            .push(node.handle.clone());
14330        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14331            handle: node.handle.clone(),
14332            tokens: traversal_node_tokens(&node),
14333            node: node.clone(),
14334        });
14335        graph.add_node(node.clone());
14336        package_nodes.push((package, node));
14337    }
14338
14339    for (package, node) in &package_nodes {
14340        if let Some(workspace_handle) =
14341            workspace_handle_by_root.get(&package.relative_workspace_root)
14342        {
14343            graph.add_edge(
14344                workspace_handle,
14345                &node.handle,
14346                "contains_package",
14347                Some("Cargo workspace member package".to_string()),
14348                1,
14349            );
14350        }
14351        let package_root = relativize_pathbuf(&package.package_root, root)
14352            .to_string_lossy()
14353            .replace('\\', "/");
14354        for (file, handle) in file_handle_by_path {
14355            if relative_path_inside_scope(file, &package_root) {
14356                graph.add_edge(
14357                    &node.handle,
14358                    handle,
14359                    "owns_file",
14360                    Some("Cargo package owns source file".to_string()),
14361                    1,
14362                );
14363            }
14364        }
14365        for dependency in &package.dependencies {
14366            if let Some(handles) = package_handle_by_name.get(&dependency.name)
14367                && handles.len() == 1
14368            {
14369                graph.add_edge(
14370                    &node.handle,
14371                    &handles[0],
14372                    "declares_dependency",
14373                    Some(format!("{} Cargo dependency", dependency.kind)),
14374                    1,
14375                );
14376            }
14377        }
14378        for alias in cargo_import_aliases(package)? {
14379            if let Some(handles) = package_handle_by_name.get(&alias)
14380                && handles.len() == 1
14381                && handles[0] != node.handle
14382            {
14383                graph.add_edge(
14384                    &node.handle,
14385                    &handles[0],
14386                    "uses_crate",
14387                    Some("Rust use/extern crate reference".to_string()),
14388                    1,
14389                );
14390                graph.add_edge(
14391                    &node.handle,
14392                    &handles[0],
14393                    "imports",
14394                    Some("Rust use/extern crate import".to_string()),
14395                    1,
14396                );
14397            }
14398        }
14399    }
14400
14401    Ok(())
14402}
14403
14404fn build_traversal_graph_source_with_options(
14405    root: &Path,
14406    path_hint: &Path,
14407    scope: Option<&str>,
14408    session_only: bool,
14409) -> Result<TraversalGraphBuild> {
14410    let mut graph = TraversalGraphBuild::default();
14411    let mut symbol_entries = Vec::new();
14412    let mut file_entries = Vec::new();
14413    let mut route_entries = Vec::new();
14414    let mut multiplicity_entries = Vec::new();
14415    let mut file_handle_by_path = HashMap::<String, String>::new();
14416    let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
14417    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
14418        let (gate, _cache_detail) =
14419            prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
14420        graph.warnings.extend(gate.diagnostics);
14421        let gate_source_root = gate.source_root.clone();
14422
14423        match gate.db_path {
14424            Some(db_path) if db_path.exists() => {
14425                let db = index::IndexDb::open_read_only_resilient(&db_path)?;
14426                let file_paths = db.file_paths()?;
14427                for file in file_paths {
14428                    if traversal_path_is_generated_artifact(
14429                        root,
14430                        &gate_source_root,
14431                        Path::new(&file),
14432                    ) {
14433                        continue;
14434                    }
14435                    let node = traversal_file_node(root, &file);
14436                    let entry = TraversalFileIndexEntry {
14437                        handle: node.handle.clone(),
14438                        tokens: traversal_node_tokens(&node),
14439                        node: node.clone(),
14440                    };
14441                    if let Some(path) = entry.node.path.as_ref() {
14442                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
14443                    }
14444                    graph.add_node(node);
14445                    file_entries.push(entry);
14446                }
14447
14448                let symbols = db.all_symbols()?;
14449                // AST parent/child lookup is per-file; using the root symbol set
14450                // here turns large graph refreshes into quadratic full-index scans.
14451                let mut symbols_by_file = HashMap::<String, Vec<&index::StoredSymbol>>::new();
14452                for symbol in &symbols {
14453                    symbols_by_file
14454                        .entry(symbol.file.clone())
14455                        .or_default()
14456                        .push(symbol);
14457                }
14458                let mut symbol_by_file_name_line = HashMap::new();
14459                let mut span_by_file_name_line = HashMap::new();
14460                let mut first_symbol_by_name = BTreeMap::<String, String>::new();
14461                let mut first_span_by_name = BTreeMap::<String, String>::new();
14462                let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
14463                let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
14464                for symbol in symbols.iter().filter(|symbol| {
14465                    !traversal_path_is_generated_artifact(
14466                        root,
14467                        &gate_source_root,
14468                        Path::new(&symbol.file),
14469                    )
14470                }) {
14471                    let node = traversal_symbol_node(root, symbol);
14472                    let file = relativize(&symbol.file, root);
14473                    symbol_by_file_name_line.insert(
14474                        format!("{file}:{}:{}", symbol.line, symbol.name),
14475                        node.handle.clone(),
14476                    );
14477                    first_symbol_by_name
14478                        .entry(symbol.name.clone())
14479                        .or_insert_with(|| node.handle.clone());
14480                    let entry = TraversalSymbolIndexEntry {
14481                        handle: node.handle.clone(),
14482                        tokens: traversal_node_tokens(&node),
14483                        node: node.clone(),
14484                    };
14485                    graph.add_node(node.clone());
14486                    if let Some(file_handle) = file_handle_by_path.get(&file) {
14487                        graph.add_edge(
14488                            file_handle,
14489                            &node.handle,
14490                            "defines",
14491                            Some("file defines symbol".to_string()),
14492                            1,
14493                        );
14494                    }
14495                    if !source_by_file.contains_key(&symbol.file) {
14496                        let source_path =
14497                            traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
14498                        source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
14499                    }
14500                    if let Some(Some(source)) = source_by_file.get(&symbol.file)
14501                        && let Some((ast_node, mut ast_entry)) =
14502                            traversal_ast_span_node(
14503                                root,
14504                                symbol,
14505                                source,
14506                                symbols_by_file
14507                                    .get(&symbol.file)
14508                                    .map(Vec::as_slice)
14509                                    .unwrap_or(&[]),
14510                            )
14511                    {
14512                        ast_entry.symbol_handle = node.handle.clone();
14513                        ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
14514                        span_by_file_name_line.insert(
14515                            format!("{file}:{}:{}", symbol.line, symbol.name),
14516                            ast_node.handle.clone(),
14517                        );
14518                        first_span_by_name
14519                            .entry(symbol.name.clone())
14520                            .or_insert_with(|| ast_node.handle.clone());
14521                        graph.add_node(ast_node.clone());
14522                        graph.add_edge(
14523                            &node.handle,
14524                            &ast_node.handle,
14525                            "has_ast_span",
14526                            Some("symbol projects to indexed AST span".to_string()),
14527                            1,
14528                        );
14529                        graph.add_edge(
14530                            &ast_node.handle,
14531                            &node.handle,
14532                            "represents_symbol",
14533                            Some("AST span represents indexed symbol".to_string()),
14534                            1,
14535                        );
14536                        ast_entries.push(ast_entry);
14537                    }
14538                    symbol_entries.push(entry);
14539                }
14540                link_ast_navigation_edges(&mut graph, &ast_entries);
14541                link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
14542
14543                if !bounded_session_projection {
14544                    for edge in db.all_stored_edges()? {
14545                        if traversal_path_is_generated_artifact(
14546                            root,
14547                            &gate_source_root,
14548                            Path::new(&edge.caller_file),
14549                        ) {
14550                            continue;
14551                        }
14552                        let caller_file = relativize(&edge.caller_file, root);
14553                        let caller_key =
14554                            format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
14555                        let Some(caller_handle) =
14556                            symbol_by_file_name_line.get(&caller_key).cloned()
14557                        else {
14558                            continue;
14559                        };
14560                        let callee_handle = if let Some(handle) =
14561                            first_symbol_by_name.get(&edge.callee_name)
14562                        {
14563                            handle.clone()
14564                        } else {
14565                            let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
14566                            let handle = node.handle.clone();
14567                            graph.add_node(node);
14568                            handle
14569                        };
14570                        graph.add_edge(
14571                            &caller_handle,
14572                            &callee_handle,
14573                            "calls",
14574                            Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
14575                            1,
14576                        );
14577                        if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
14578                            && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
14579                        {
14580                            graph.add_edge(
14581                                caller_span,
14582                                callee_span,
14583                                "calls",
14584                                Some(format!(
14585                                    "AST call site {}:{}",
14586                                    caller_file, edge.call_site_line
14587                                )),
14588                                1,
14589                            );
14590                        }
14591                    }
14592                }
14593
14594                for route in db.all_routes()? {
14595                    if traversal_path_is_generated_artifact(
14596                        root,
14597                        &gate_source_root,
14598                        Path::new(&route.file),
14599                    ) {
14600                        continue;
14601                    }
14602                    let node = traversal_route_node(root, &route);
14603                    let entry = TraversalRouteIndexEntry {
14604                        handle: node.handle.clone(),
14605                        tokens: traversal_node_tokens(&node),
14606                        node: node.clone(),
14607                    };
14608                    graph.add_node(node.clone());
14609                    if let Some(path) = node.path.as_ref()
14610                        && let Some(file_handle) = file_handle_by_path.get(path)
14611                    {
14612                        graph.add_edge(
14613                            file_handle,
14614                            &node.handle,
14615                            "defines_route",
14616                            Some("file declares route".to_string()),
14617                            1,
14618                        );
14619                    }
14620                    let handler_handle =
14621                        if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
14622                            handle.clone()
14623                        } else {
14624                            let node = traversal_unresolved_symbol_node(root, &route.handler_name);
14625                            let handle = node.handle.clone();
14626                            graph.add_node(node);
14627                            handle
14628                        };
14629                    graph.add_edge(
14630                        &entry.handle,
14631                        &handler_handle,
14632                        "handled_by",
14633                        Some("route handler reference".to_string()),
14634                        1,
14635                    );
14636                    if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
14637                        graph.add_edge(
14638                            &entry.handle,
14639                            handler_span,
14640                            "handled_by",
14641                            Some("route handler AST span".to_string()),
14642                            1,
14643                        );
14644                        graph.add_edge(
14645                            handler_span,
14646                            &entry.handle,
14647                            "handles_route",
14648                            Some("AST span handles route".to_string()),
14649                            1,
14650                        );
14651                    }
14652                    route_entries.push(entry);
14653                }
14654            }
14655            _ => {
14656                add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
14657                    .with_context(|| {
14658                    format!(
14659                        "loading raw source fallback nodes from {}",
14660                        gate_source_root.display()
14661                    )
14662                })?;
14663                for entry in &file_entries {
14664                    if let Some(path) = entry.node.path.as_ref() {
14665                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
14666                    }
14667                }
14668            }
14669        }
14670        load_multiplicity_traversal_nodes(
14671            root,
14672            &gate_source_root,
14673            &mut graph,
14674            &file_handle_by_path,
14675            &mut multiplicity_entries,
14676        )?;
14677    }
14678
14679    let code_lookup = TraversalCodeLookup::new(
14680        &symbol_entries,
14681        &file_entries,
14682        &route_entries,
14683        &multiplicity_entries,
14684    );
14685    load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14686    Ok(graph)
14687}
14688
14689#[cfg(test)]
14690fn build_traversal_graph_source(
14691    root: &Path,
14692    path_hint: &Path,
14693    scope: Option<&str>,
14694) -> Result<TraversalGraphBuild> {
14695    build_traversal_graph_source_with_options(root, path_hint, scope, false)
14696}
14697
14698/// Bounded acquire deadline for the graph-db cross-process write lock. flock
14699/// releases automatically if the holder dies, so a live writer is the only thing
14700/// that can hold this; the bound serializes brief contention and fails closed
14701/// with a clear diagnostic on a wedged holder rather than hanging forever.
14702const GRAPH_DB_WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(15);
14703const GRAPH_DB_WRITE_LOCK_POLL: Duration = Duration::from_millis(50);
14704
14705/// RAII guard for the graph-db advisory write lock; unlocks on drop.
14706pub(crate) struct GraphDbWriteLock {
14707    file: std::fs::File,
14708}
14709
14710impl Drop for GraphDbWriteLock {
14711    fn drop(&mut self) {
14712        let _ = fs4::fs_std::FileExt::unlock(&self.file);
14713    }
14714}
14715
14716pub(crate) fn graph_db_write_lock_path(graph_db: &Path) -> PathBuf {
14717    let stem = graph_db
14718        .file_stem()
14719        .and_then(|stem| stem.to_str())
14720        .unwrap_or("graph");
14721    graph_db.with_file_name(format!("{stem}.write.lock"))
14722}
14723
14724/// Acquire the cross-process advisory write lock guarding graph-db refresh/write
14725/// and snapshot-import against concurrent agent processes. SQLite's busy_timeout
14726/// alone left the write transaction and the snapshot-import rename window racing
14727/// concurrent writers (#gdbwritelock): a refresh could create `-wal`/`-shm`
14728/// sidecars mid-rename and corrupt the freshly imported db, and parallel
14729/// refreshes spuriously failed `database is locked`. This serializes them.
14730pub(crate) fn acquire_graph_db_write_lock(graph_db: &Path) -> Result<GraphDbWriteLock> {
14731    acquire_graph_db_write_lock_with_timeout(graph_db, GRAPH_DB_WRITE_LOCK_TIMEOUT)
14732}
14733
14734pub(crate) fn acquire_graph_db_write_lock_with_timeout(
14735    graph_db: &Path,
14736    timeout: Duration,
14737) -> Result<GraphDbWriteLock> {
14738    use fs4::fs_std::FileExt;
14739
14740    let lock_path = graph_db_write_lock_path(graph_db);
14741    if let Some(parent) = lock_path.parent() {
14742        fs::create_dir_all(parent)
14743            .with_context(|| format!("creating graph-db lock dir: {}", parent.display()))?;
14744    }
14745    let file = std::fs::OpenOptions::new()
14746        .read(true)
14747        .write(true)
14748        .create(true)
14749        .truncate(false)
14750        .open(&lock_path)
14751        .with_context(|| format!("opening graph-db write lock {}", lock_path.display()))?;
14752
14753    let deadline = Instant::now() + timeout;
14754    loop {
14755        match file.try_lock_exclusive() {
14756            Ok(true) => return Ok(GraphDbWriteLock { file }),
14757            Ok(false) => {
14758                if Instant::now() >= deadline {
14759                    bail!(
14760                        "another tsift graph-db writer is active for {} (lock: {}); \
14761                         a concurrent graph-db refresh or snapshot-import is in progress, \
14762                         wait for it to finish before retrying",
14763                        graph_db.display(),
14764                        lock_path.display()
14765                    );
14766                }
14767                std::thread::sleep(GRAPH_DB_WRITE_LOCK_POLL);
14768            }
14769            Err(err) => {
14770                return Err(err).with_context(|| {
14771                    format!("locking graph-db write lock {}", lock_path.display())
14772                });
14773            }
14774        }
14775    }
14776}
14777
14778pub(crate) fn write_traversal_graph_store_with_options(
14779    root: &Path,
14780    path_hint: &Path,
14781    scope: Option<&str>,
14782    session_only: bool,
14783) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14784    let source_graph =
14785        build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14786    let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14787    let graph_db = graph_substrate_db_path(root, scope);
14788    // Serialize the write against concurrent refresh/snapshot-import (#gdbwritelock).
14789    let _write_lock = acquire_graph_db_write_lock(&graph_db)?;
14790    let mut store = SqliteGraphStore::open(&graph_db)?;
14791    let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14792        .ok()
14793        .flatten()
14794        .or_else(|| graph_projection_content_hash(&projection));
14795    let refresh = store.replace_projection_with_version(
14796        scope.unwrap_or("root"),
14797        &projection,
14798        Some(GRAPH_PROJECTION_VERSION),
14799        source_watermark,
14800    )?;
14801    Ok((source_graph, refresh))
14802}
14803
14804pub(crate) fn write_traversal_graph_store(
14805    root: &Path,
14806    path_hint: &Path,
14807    scope: Option<&str>,
14808) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14809    write_traversal_graph_store_with_options(root, path_hint, scope, false)
14810}
14811
14812fn refresh_traversal_graph_store_with_options(
14813    root: &Path,
14814    path_hint: &Path,
14815    scope: Option<&str>,
14816    session_only: bool,
14817) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14818    let (source_graph, refresh) =
14819        write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14820    let graph_db = graph_substrate_db_path(root, scope);
14821    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14822    let mut graph = traversal_graph_from_store(root, &store)?;
14823    graph.warnings = source_graph.warnings;
14824    Ok((graph, refresh))
14825}
14826
14827fn refresh_traversal_graph_store(
14828    root: &Path,
14829    path_hint: &Path,
14830    scope: Option<&str>,
14831) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14832    refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14833}
14834
14835pub(crate) fn build_traversal_graph(
14836    root: &Path,
14837    path_hint: &Path,
14838    scope: Option<&str>,
14839) -> Result<TraversalGraphBuild> {
14840    let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14841    Ok(graph)
14842}
14843
14844fn traversal_query_kind_priority(kind: &str) -> usize {
14845    match kind {
14846        "backlog" => 0,
14847        "job_packet" => 1,
14848        "worker_result" => 2,
14849        "symbol" => 3,
14850        "ast_span" => 4,
14851        "file" => 5,
14852        "route" => 6,
14853        "cargo_package" => 7,
14854        "cargo_workspace" => 8,
14855        "session" => 9,
14856        "semantic_concept" => 10,
14857        "semantic_entity" => 11,
14858        _ => 12,
14859    }
14860}
14861
14862fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14863    let trimmed = query.trim();
14864    if trimmed.is_empty() {
14865        return None;
14866    }
14867    let kind_priority = traversal_query_kind_priority(&node.kind);
14868    if node.handle == trimmed {
14869        return Some((0, kind_priority, node.handle.clone()));
14870    }
14871    if node.path.as_deref() == Some(trimmed) {
14872        let path_priority = if node.kind == "file" {
14873            0
14874        } else {
14875            kind_priority.saturating_add(1)
14876        };
14877        return Some((1, path_priority, node.handle.clone()));
14878    }
14879    let normalized_backlog = trimmed.trim_start_matches('#');
14880    if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14881    {
14882        return Some((2, kind_priority, node.handle.clone()));
14883    }
14884    if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14885        return Some((3, kind_priority, node.handle.clone()));
14886    }
14887    None
14888}
14889
14890fn resolve_traversal_node<'a>(
14891    graph: &'a TraversalGraphBuild,
14892    query: &str,
14893) -> Option<&'a TraversalNode> {
14894    graph
14895        .nodes
14896        .values()
14897        .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14898        .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14899        .map(|(_, node)| node)
14900}
14901
14902fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14903    let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14904    for edge in edges {
14905        adj.entry(edge.from.clone())
14906            .or_default()
14907            .insert(edge.to.clone());
14908        adj.entry(edge.to.clone())
14909            .or_default()
14910            .insert(edge.from.clone());
14911    }
14912    adj.into_iter()
14913        .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14914        .collect()
14915}
14916
14917fn traversal_shortest_handles(
14918    edges: &[TraversalEdge],
14919    from: &str,
14920    to: &str,
14921) -> Option<Vec<String>> {
14922    if from == to {
14923        return Some(vec![from.to_string()]);
14924    }
14925    let adj = traversal_adjacency(edges);
14926    if !adj.contains_key(from) || !adj.contains_key(to) {
14927        return None;
14928    }
14929    let mut visited = BTreeSet::new();
14930    let mut queue = VecDeque::new();
14931    let mut parent = BTreeMap::<String, String>::new();
14932    visited.insert(from.to_string());
14933    queue.push_back(from.to_string());
14934    while let Some(current) = queue.pop_front() {
14935        if let Some(neighbors) = adj.get(&current) {
14936            for neighbor in neighbors {
14937                if visited.insert(neighbor.clone()) {
14938                    parent.insert(neighbor.clone(), current.clone());
14939                    if neighbor == to {
14940                        let mut path = vec![to.to_string()];
14941                        let mut cursor = to.to_string();
14942                        while let Some(prev) = parent.get(&cursor) {
14943                            path.push(prev.clone());
14944                            cursor = prev.clone();
14945                        }
14946                        path.reverse();
14947                        return Some(path);
14948                    }
14949                    queue.push_back(neighbor.clone());
14950                }
14951            }
14952        }
14953    }
14954    None
14955}
14956
14957fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14958    let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14959    for edge in edges {
14960        let neighbor = if edge.from == current {
14961            edge.to.as_str()
14962        } else if edge.to == current {
14963            edge.from.as_str()
14964        } else {
14965            continue;
14966        };
14967        let score = traversal_relation_score(edge, current);
14968        best_score_by_neighbor
14969            .entry(neighbor.to_string())
14970            .and_modify(|best| *best = (*best).max(score))
14971            .or_insert(score);
14972    }
14973    let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14974    ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14975        right_score
14976            .cmp(left_score)
14977            .then_with(|| left_handle.cmp(right_handle))
14978    });
14979    ranked.into_iter().map(|(handle, _)| handle).collect()
14980}
14981
14982fn traversal_neighborhood_handles(
14983    edges: &[TraversalEdge],
14984    origin: &str,
14985    depth: usize,
14986    limit: usize,
14987) -> BTreeSet<String> {
14988    let mut seen = BTreeSet::new();
14989    let mut queue = VecDeque::new();
14990    seen.insert(origin.to_string());
14991    queue.push_back((origin.to_string(), 0usize));
14992    while let Some((current, current_depth)) = queue.pop_front() {
14993        if current_depth >= depth {
14994            continue;
14995        }
14996        for neighbor in traversal_scored_neighbors(edges, &current) {
14997            if limit > 0 && seen.len() >= limit {
14998                return seen;
14999            }
15000            if seen.insert(neighbor.clone()) {
15001                queue.push_back((neighbor, current_depth + 1));
15002            }
15003        }
15004    }
15005    seen
15006}
15007
15008fn traversal_edges_between(
15009    handles: &BTreeSet<String>,
15010    edges: &[TraversalEdge],
15011) -> Vec<TraversalEdge> {
15012    edges
15013        .iter()
15014        .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
15015        .cloned()
15016        .collect()
15017}
15018
15019fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
15020    let mut result = Vec::new();
15021    for pair in path.windows(2) {
15022        if let Some(edge) = edges.iter().find(|edge| {
15023            (edge.from == pair[0] && edge.to == pair[1])
15024                || (edge.from == pair[1] && edge.to == pair[0])
15025        }) {
15026            result.push(edge.clone());
15027        }
15028    }
15029    result
15030}
15031
15032fn sorted_traversal_nodes<'a>(
15033    nodes: impl IntoIterator<Item = &'a TraversalNode>,
15034) -> Vec<TraversalNode> {
15035    let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
15036    nodes.sort_by(|left, right| {
15037        left.kind
15038            .cmp(&right.kind)
15039            .then_with(|| left.label.cmp(&right.label))
15040            .then_with(|| left.path.cmp(&right.path))
15041            .then_with(|| left.handle.cmp(&right.handle))
15042    });
15043    nodes
15044}
15045
15046fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
15047    let base = match edge.relation.as_str() {
15048        "mentions" => 100,
15049        "contains" => 80,
15050        "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
15051        "contains_embedded_symbol" | "embedded_in_fence" => 77,
15052        "contains_markdown_block"
15053        | "contains_embedded_code"
15054        | "enclosing_module"
15055        | "enclosing_section" => 76,
15056        "calls" => {
15057            if edge.from == origin {
15058                70
15059            } else {
15060                65
15061            }
15062        }
15063        "handled_by" | "handles_route" => 68,
15064        "defines_route" => 62,
15065        "imports" => 62,
15066        "previous_sibling" | "next_sibling" => 54,
15067        "mentions_concept" | "mentions_entity" => 66,
15068        "semantic_relation" => 64,
15069        "tagged_concept" | "related_concept" => 58,
15070        "defines" => {
15071            if edge.from == origin {
15072                60
15073            } else {
15074                55
15075            }
15076        }
15077        _ => 10,
15078    };
15079    base + edge.weight
15080}
15081
15082fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
15083    match edge.relation.as_str() {
15084        "mentions" => "matched from backlog/session text".to_string(),
15085        "contains" => "contained in the selected session artifact".to_string(),
15086        "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
15087        "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
15088        "parent" => "parent AST span".to_string(),
15089        "child" => "child AST span".to_string(),
15090        "previous_sibling" => "previous AST sibling".to_string(),
15091        "next_sibling" => "next AST sibling".to_string(),
15092        "contains_markdown_block" => "Markdown section block".to_string(),
15093        "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
15094        "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
15095        "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
15096        "enclosing_module" => "nearest enclosing module".to_string(),
15097        "enclosing_section" => "nearest enclosing Markdown section".to_string(),
15098        "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
15099        "defines" => "file that defines the selected symbol".to_string(),
15100        "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
15101        "defines_route" => "file that declares the selected route".to_string(),
15102        "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
15103        "handled_by" => "route handled by the selected symbol".to_string(),
15104        "handles_route" => "route handled by the selected AST span".to_string(),
15105        "imports" => "import dependency from the selected package".to_string(),
15106        "mentions_concept" => "cached summary concept for the selected source".to_string(),
15107        "mentions_entity" => "cached summary entity for the selected source".to_string(),
15108        "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
15109        "tagged_concept" => "concept label attached to the selected entity".to_string(),
15110        "related_concept" => "co-occurring cached summary concept".to_string(),
15111        "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
15112        "calls" => "caller of the selected symbol".to_string(),
15113        other => format!("connected by {other}"),
15114    }
15115}
15116
15117fn traversal_recommendations(
15118    graph: &TraversalGraphBuild,
15119    origin: Option<&str>,
15120    shortest_path: Option<&[String]>,
15121    limit: usize,
15122) -> Vec<TraversalRecommendation> {
15123    let Some(origin) = origin else {
15124        return Vec::new();
15125    };
15126    let mut recommendations = Vec::new();
15127    let mut seen = BTreeSet::new();
15128
15129    if let Some(path) = shortest_path
15130        && path.len() > 1
15131        && path.first().is_some_and(|handle| handle == origin)
15132        && let Some(next) = graph.nodes.get(&path[1])
15133    {
15134        seen.insert(next.handle.clone());
15135        recommendations.push(TraversalRecommendation {
15136            handle: next.handle.clone(),
15137            kind: next.kind.clone(),
15138            label: next.label.clone(),
15139            reason: "next hop on shortest path".to_string(),
15140            score: 1_000,
15141            expand: next.expand.clone(),
15142        });
15143    }
15144
15145    let mut candidates = graph
15146        .edges
15147        .iter()
15148        .filter_map(|edge| {
15149            let neighbor = if edge.from == origin {
15150                edge.to.as_str()
15151            } else if edge.to == origin {
15152                edge.from.as_str()
15153            } else {
15154                return None;
15155            };
15156            let node = graph.nodes.get(neighbor)?;
15157            Some((traversal_relation_score(edge, origin), edge, node))
15158        })
15159        .collect::<Vec<_>>();
15160    candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
15161        right_score
15162            .cmp(left_score)
15163            .then_with(|| left.kind.cmp(&right.kind))
15164            .then_with(|| left.label.cmp(&right.label))
15165            .then_with(|| left.handle.cmp(&right.handle))
15166    });
15167
15168    let max = if limit == 0 { usize::MAX } else { limit };
15169    for (score, edge, node) in candidates {
15170        if recommendations.len() >= max {
15171            break;
15172        }
15173        if seen.insert(node.handle.clone()) {
15174            recommendations.push(TraversalRecommendation {
15175                handle: node.handle.clone(),
15176                kind: node.kind.clone(),
15177                label: node.label.clone(),
15178                reason: traversal_recommendation_reason(edge, origin),
15179                score,
15180                expand: node.expand.clone(),
15181            });
15182        }
15183    }
15184
15185    recommendations
15186}
15187
15188fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
15189    let scale = nodes.saturating_add(edges);
15190    if scale <= 80 {
15191        ExplorationBudget {
15192            project_size: "small".to_string(),
15193            max_source_windows: 8,
15194            lines_per_window: 96,
15195            relationship_limit: 40,
15196        }
15197    } else if scale <= 800 {
15198        ExplorationBudget {
15199            project_size: "medium".to_string(),
15200            max_source_windows: 6,
15201            lines_per_window: 80,
15202            relationship_limit: 32,
15203        }
15204    } else {
15205        ExplorationBudget {
15206            project_size: "large".to_string(),
15207            max_source_windows: 4,
15208            lines_per_window: 64,
15209            relationship_limit: 24,
15210        }
15211    }
15212}
15213
15214fn exploration_node_label(node: &TraversalNode) -> String {
15215    format!("{}:{}", node.kind, node.label)
15216}
15217
15218fn exploration_source_window_for_node(
15219    root: &Path,
15220    node: &TraversalNode,
15221    budget: &ExplorationBudget,
15222) -> Option<ExplorationSourceWindow> {
15223    let file = node.path.as_ref()?;
15224    let anchor = node
15225        .line
15226        .and_then(|line| usize::try_from(line).ok())
15227        .and_then(|line| line.checked_add(1))
15228        .unwrap_or(1);
15229    let context_before = budget.lines_per_window / 3;
15230    let start = anchor.saturating_sub(context_before).max(1);
15231    let end = start
15232        .saturating_add(budget.lines_per_window)
15233        .saturating_sub(1);
15234    let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
15235    Some(ExplorationSourceWindow {
15236        handle,
15237        file: file.clone(),
15238        start,
15239        end,
15240        reason: format!("cluster around {}", exploration_node_label(node)),
15241        expand: source_read_command(root, file, start, budget.lines_per_window),
15242    })
15243}
15244
15245fn build_exploration_packet(
15246    root: &Path,
15247    totals: &TraversalTotals,
15248    selected_nodes: &[TraversalNode],
15249    selected_edges: &[TraversalEdge],
15250) -> ExplorationPacket {
15251    let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
15252    let node_by_handle = selected_nodes
15253        .iter()
15254        .map(|node| (node.handle.as_str(), node))
15255        .collect::<BTreeMap<_, _>>();
15256    let relationship_map = selected_edges
15257        .iter()
15258        .take(budget.relationship_limit)
15259        .filter_map(|edge| {
15260            let from = node_by_handle.get(edge.from.as_str())?;
15261            let to = node_by_handle.get(edge.to.as_str())?;
15262            Some(ExplorationRelation {
15263                from: exploration_node_label(from),
15264                relation: edge.relation.clone(),
15265                to: exploration_node_label(to),
15266                label: edge.label.clone(),
15267            })
15268        })
15269        .collect::<Vec<_>>();
15270
15271    let mut seen_windows = BTreeSet::new();
15272    let mut source_windows = Vec::new();
15273    for node in selected_nodes {
15274        if source_windows.len() >= budget.max_source_windows {
15275            break;
15276        }
15277        let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
15278            continue;
15279        };
15280        let key = (window.file.clone(), window.start, window.end);
15281        if seen_windows.insert(key) {
15282            source_windows.push(window);
15283        }
15284    }
15285
15286    ExplorationPacket {
15287        budget,
15288        relationship_map,
15289        source_windows,
15290        worker_context: Vec::new(),
15291        no_reread_guidance:
15292            "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
15293                .to_string(),
15294    }
15295}
15296
15297pub(crate) fn traversal_report(
15298    root: &Path,
15299    scope: Option<&str>,
15300    graph: TraversalGraphBuild,
15301    query: Option<&str>,
15302    target: Option<&str>,
15303    depth: usize,
15304    limit: usize,
15305) -> Result<TraversalReport> {
15306    let totals = TraversalTotals {
15307        nodes: graph.nodes.len(),
15308        edges: graph.edges.len(),
15309    };
15310    let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
15311    let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
15312    if let Some(query) = query
15313        && origin_node.is_none()
15314    {
15315        bail!("traversal node not found: {}", query);
15316    }
15317    if let Some(target) = target
15318        && target_node.is_none()
15319    {
15320        bail!("traversal target not found: {}", target);
15321    }
15322
15323    let (mode, selected_nodes, selected_edges, shortest_path) =
15324        if let (Some(origin), Some(target)) = (origin_node, target_node) {
15325            if let Some(handles) =
15326                traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
15327            {
15328                let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
15329                let nodes = handles
15330                    .iter()
15331                    .filter_map(|handle| graph.nodes.get(handle).cloned())
15332                    .collect::<Vec<_>>();
15333                let edges = traversal_path_edges(&handles, &graph.edges);
15334                let path = TraversalPathReport {
15335                    from: origin.clone(),
15336                    to: target.clone(),
15337                    hops: handles.len().saturating_sub(1),
15338                    nodes: nodes.clone(),
15339                    edges: edges.clone(),
15340                };
15341                (
15342                    "path".to_string(),
15343                    nodes,
15344                    traversal_edges_between(&handle_set, &graph.edges),
15345                    Some(path),
15346                )
15347            } else {
15348                (
15349                    "path".to_string(),
15350                    vec![origin.clone(), target.clone()],
15351                    Vec::new(),
15352                    None,
15353                )
15354            }
15355        } else if let Some(origin) = origin_node {
15356            let handles =
15357                traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
15358            let nodes =
15359                sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
15360            let edges = traversal_edges_between(&handles, &graph.edges);
15361            ("neighborhood".to_string(), nodes, edges, None)
15362        } else {
15363            let mut nodes = sorted_traversal_nodes(graph.nodes.values());
15364            let truncated_nodes = limit > 0 && nodes.len() > limit;
15365            if truncated_nodes {
15366                nodes.truncate(limit);
15367            }
15368            let handles = nodes
15369                .iter()
15370                .map(|node| node.handle.clone())
15371                .collect::<BTreeSet<_>>();
15372            let mut edges = traversal_edges_between(&handles, &graph.edges);
15373            let truncated_edges = limit > 0 && edges.len() > limit;
15374            if truncated_edges {
15375                edges.truncate(limit);
15376            }
15377            ("export".to_string(), nodes, edges, None)
15378        };
15379
15380    let shortest_handles = shortest_path.as_ref().map(|path| {
15381        path.nodes
15382            .iter()
15383            .map(|node| node.handle.clone())
15384            .collect::<Vec<_>>()
15385    });
15386    let recommendations = traversal_recommendations(
15387        &graph,
15388        origin_node.map(|node| node.handle.as_str()),
15389        shortest_handles.as_deref(),
15390        if limit == 0 { 10 } else { limit.min(10) },
15391    );
15392    let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
15393    let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
15394
15395    Ok(TraversalReport {
15396        root: root.to_string_lossy().to_string(),
15397        scope: scope.map(str::to_string),
15398        mode,
15399        totals,
15400        query: query.map(str::to_string),
15401        target: target.map(str::to_string),
15402        nodes: selected_nodes,
15403        edges: selected_edges,
15404        shortest_path,
15405        recommendations,
15406        exploration,
15407        truncated,
15408        warnings: graph.warnings,
15409    })
15410}
15411
15412fn html_escape(input: &str) -> String {
15413    input
15414        .replace('&', "&amp;")
15415        .replace('<', "&lt;")
15416        .replace('>', "&gt;")
15417        .replace('"', "&quot;")
15418        .replace('\'', "&#39;")
15419}
15420
15421pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
15422    let json = serde_json::to_string(report)?.replace("</", "<\\/");
15423    let mut html = String::new();
15424    html.push_str(
15425        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
15426    );
15427    html.push_str(
15428        r#"<style>
15429:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
15430@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
15431*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.4}.page{max-width:1280px;margin:0 auto;padding:20px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:14px}.top h1{font-size:22px;margin:0}.meta{color:var(--muted);font-size:13px}.toolbar{display:flex;gap:8px;align-items:center}.toolbar input{min-width:220px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--text);padding:8px 10px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:14px;min-height:650px}.graph-panel,.side{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.graph-panel{position:relative}.legend{position:absolute;left:12px;top:12px;display:flex;flex-wrap:wrap;gap:6px;max-width:calc(100% - 24px)}.legend span{font-size:12px;background:color-mix(in srgb,var(--panel) 86%,transparent);border:1px solid var(--line);border-radius:999px;padding:4px 8px}.side{padding:14px;overflow:auto}.side h2{font-size:15px;margin:0 0 8px}.selected{border-top:1px solid var(--line);margin-top:12px;padding-top:12px}.list{display:grid;gap:8px}.row{border:1px solid var(--line);border-radius:6px;padding:8px;cursor:pointer}.row:hover{border-color:var(--accent)}.kind{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}.label{font-weight:650;overflow-wrap:anywhere}.handle,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted)}svg{width:100%;height:650px;display:block}.edge{stroke:var(--edge);stroke-width:1.4;opacity:.72}.edge.semantic{stroke:var(--semantic);stroke-width:1.8}.node{stroke:var(--panel);stroke-width:2;cursor:pointer}.node.semantic{stroke:var(--semantic);stroke-width:2.5}.node-label{font-size:12px;paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round;fill:var(--text);pointer-events:none}.hidden{display:none}@media(max-width:900px){.top{display:block}.toolbar{margin-top:12px}.layout{grid-template-columns:1fr}.side{max-height:360px}svg{height:560px}}
15432</style>"#,
15433    );
15434    html.push_str("</head><body>");
15435    html.push_str("<div class=\"page\">");
15436    html.push_str(&format!(
15437        "<header class=\"top\"><div><h1>tsift traversal graph</h1><div class=\"meta\">mode <code>{}</code> | nodes <code>{}</code>/<code>{}</code> | edges <code>{}</code>/<code>{}</code></div></div><div class=\"toolbar\"><input id=\"filter\" type=\"search\" placeholder=\"Filter nodes\"></div></header>",
15438        html_escape(&report.mode),
15439        report.nodes.len(),
15440        report.totals.nodes,
15441        report.edges.len(),
15442        report.totals.edges
15443    ));
15444    html.push_str(
15445        r#"<main class="layout"><section class="graph-panel"><div id="legend" class="legend"></div><svg id="graph-canvas" role="img" aria-label="Traversal graph"></svg></section><aside class="side"><h2>Nodes</h2><div id="node-list" class="list"></div><div id="selected" class="selected"></div></aside></main>"#,
15446    );
15447    html.push_str("<script id=\"graph-data\" type=\"application/json\">");
15448    html.push_str(&json);
15449    html.push_str(
15450        r##"</script><script>
15451const report = JSON.parse(document.getElementById("graph-data").textContent);
15452const svg = document.getElementById("graph-canvas");
15453const list = document.getElementById("node-list");
15454const selected = document.getElementById("selected");
15455const filter = document.getElementById("filter");
15456const legend = document.getElementById("legend");
15457const nodes = report.nodes.map((node, index) => ({...node, index}));
15458const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
15459const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
15460const colorByKind = new Map([
15461  ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
15462  ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
15463  ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
15464  ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
15465]);
15466function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
15467function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
15468function text(value){ return value == null ? "" : String(value); }
15469function matches(node, query){
15470  if (!query) return true;
15471  const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
15472  return haystack.includes(query);
15473}
15474function layout(){
15475  const rect = svg.getBoundingClientRect();
15476  const width = rect.width || 900;
15477  const height = rect.height || 650;
15478  const cx = width / 2;
15479  const cy = height / 2;
15480  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15481  const counts = new Map();
15482  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
15483  const offsets = new Map();
15484  for (const node of nodes) {
15485    const group = kinds.indexOf(node.kind);
15486    const index = offsets.get(node.kind) || 0;
15487    offsets.set(node.kind, index + 1);
15488    const groupCount = counts.get(node.kind) || 1;
15489    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
15490    const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
15491    node.x = cx + Math.cos(angle) * ring;
15492    node.y = cy + Math.sin(angle) * ring;
15493  }
15494}
15495function draw(){
15496  const query = filter.value.trim().toLowerCase();
15497  const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
15498  svg.innerHTML = "";
15499  for (const edge of edges) {
15500    if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
15501    const from = nodeByHandle.get(edge.from);
15502    const to = nodeByHandle.get(edge.to);
15503    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
15504    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
15505    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
15506    line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
15507    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
15508    svg.appendChild(line);
15509  }
15510  for (const node of nodes) {
15511    if (!visible.has(node.handle)) continue;
15512    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
15513    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
15514    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
15515    circle.setAttribute("fill", color(node.kind));
15516    circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
15517    circle.addEventListener("click", () => selectNode(node));
15518    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
15519    svg.appendChild(circle);
15520    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
15521    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
15522    label.setAttribute("class", "node-label");
15523    label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
15524    svg.appendChild(label);
15525  }
15526  renderList(query);
15527}
15528function renderLegend(){
15529  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15530  legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">&#9679;</b> ${kind}</span>`).join("");
15531}
15532function renderList(query){
15533  const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
15534  list.innerHTML = rows.map(node => `<div class="row" data-handle="${node.handle}"><div class="kind">${node.kind}</div><div class="label">${escapeHtml(node.label)}</div><div class="handle">${node.handle}</div></div>`).join("");
15535  for (const row of list.querySelectorAll(".row")) {
15536    row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
15537  }
15538}
15539function selectNode(node){
15540  const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
15541  selected.innerHTML = `<h2>${escapeHtml(node.label)}</h2><div class="kind">${node.kind}</div><p class="handle">${node.handle}</p>${node.path ? `<p>${escapeHtml(node.path)}${node.line != null ? ":" + node.line : ""}</p>` : ""}${node.detail ? `<p>${escapeHtml(node.detail)}</p>` : ""}<p><code>${escapeHtml(node.expand)}</code></p><h2>Edges</h2><div class="list">${adjacent.map(edge => `<div class="row"><div class="kind">${edge.relation}</div><div>${escapeHtml(edge.from)} -> ${escapeHtml(edge.to)}</div>${edge.label ? `<div>${escapeHtml(edge.label)}</div>` : ""}</div>`).join("") || "<div class=\"meta\">No visible edges.</div>"}</div>`;
15542}
15543function escapeHtml(value){
15544  return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));
15545}
15546filter.addEventListener("input", draw);
15547window.addEventListener("resize", () => { layout(); draw(); });
15548renderLegend();
15549layout();
15550draw();
15551if (nodes.length) selectNode(nodes[0]);
15552</script></div></body></html>"##,
15553    );
15554    Ok(html)
15555}
15556
15557fn semantic_related_report_from_store(
15558    root: &Path,
15559    scope: Option<&str>,
15560    query: &str,
15561    limit: usize,
15562    kind: SemanticRelatedKind,
15563    store: &impl GraphStore,
15564) -> Result<SemanticRelatedReport> {
15565    if query.trim().is_empty() {
15566        bail!("semantic query cannot be empty");
15567    }
15568
15569    let query_embedding = semantic_embedding(query);
15570    let node_kinds: &[&str] = match kind {
15571        SemanticRelatedKind::Concept => &["semantic_concept"],
15572        SemanticRelatedKind::Entity => &["semantic_entity"],
15573        SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
15574    };
15575
15576    let items = store
15577        .semantic_top_candidates(&query_embedding, node_kinds, limit)?
15578        .into_iter()
15579        .map(|candidate| {
15580            let node = candidate.node;
15581            SemanticRelatedItem {
15582                handle: node
15583                    .properties
15584                    .get("handle")
15585                    .cloned()
15586                    .unwrap_or_else(|| node.id.clone()),
15587                kind: node.kind,
15588                label: node.label,
15589                score: candidate.score,
15590                file_path: node
15591                    .properties
15592                    .get("source_file")
15593                    .or_else(|| node.properties.get("path"))
15594                    .cloned(),
15595                source_symbol: node.properties.get("source_symbol").cloned(),
15596                detail: node
15597                    .properties
15598                    .get("description")
15599                    .or_else(|| node.properties.get("detail"))
15600                    .cloned(),
15601                expand: node
15602                    .properties
15603                    .get("expand")
15604                    .cloned()
15605                    .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
15606            }
15607        })
15608        .collect::<Vec<_>>();
15609
15610    let mut warnings = Vec::new();
15611    if items.is_empty() {
15612        warnings.push(
15613            "no semantic graph rows found; run `tsift summarize --extract <path>` first"
15614                .to_string(),
15615        );
15616    }
15617
15618    Ok(SemanticRelatedReport {
15619        root: root.to_string_lossy().to_string(),
15620        scope: scope.map(str::to_string),
15621        query: query.to_string(),
15622        embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
15623        count: items.len(),
15624        items,
15625        warnings,
15626    })
15627}
15628
15629fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
15630    Ok(store.nodes_by_kind("semantic_concept")?.len()
15631        + store.nodes_by_kind("semantic_entity")?.len())
15632}
15633
15634fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
15635    if limit == 0 {
15636        return 0;
15637    }
15638    limit.saturating_mul(4).clamp(
15639        GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
15640        GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
15641    )
15642}
15643
15644fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
15645    if limit == 0 {
15646        return usize::MAX;
15647    }
15648    limit.saturating_mul(3).max(limit).max(seed_count)
15649}
15650
15651fn graph_db_semantic_seeded_neighborhood(
15652    store: &impl GraphStore,
15653    seed_ids: &[String],
15654    depth: usize,
15655    limit: usize,
15656) -> Result<GraphDbSemanticSeededSubgraph> {
15657    let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
15658    let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
15659    let mut diagnostics = vec![
15660        "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
15661        "seed expansion traverses both outgoing and incident edges so code, markdown, conversation, and memory adapters can link into semantic rows without reversing their edge direction".to_string(),
15662        format!(
15663            "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
15664            if edge_scan_cap == 0 {
15665                "unbounded".to_string()
15666            } else {
15667                edge_scan_cap.to_string()
15668            },
15669            if node_discovery_cap == usize::MAX {
15670                "unbounded".to_string()
15671            } else {
15672                node_discovery_cap.to_string()
15673            }
15674        ),
15675    ];
15676
15677    let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
15678        .with_edge_scan_cap(edge_scan_cap)
15679        .with_node_discovery_cap(node_discovery_cap);
15680    let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
15681
15682    for seed_id in &result.missing_seed_ids {
15683        diagnostics.push(format!(
15684            "semantic seed {seed_id} was not present in the graph store"
15685        ));
15686    }
15687
15688    if result.skipped_by_edge_cap > 0 {
15689        diagnostics.push(format!(
15690            "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
15691            result.skipped_by_edge_cap
15692        ));
15693    }
15694    if result.skipped_by_node_cap > 0 {
15695        diagnostics.push(format!(
15696            "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
15697            result.skipped_by_node_cap
15698        ));
15699    }
15700
15701    if result.truncated {
15702        diagnostics.push(format!(
15703            "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
15704            result.total_discovered
15705        ));
15706    }
15707
15708    Ok(GraphDbSemanticSeededSubgraph {
15709        nodes: result.nodes,
15710        edges: result.edges,
15711        truncated: result.truncated,
15712        diagnostics,
15713    })
15714}
15715
15716#[allow(clippy::too_many_arguments)]
15717fn cmd_semantic_related(
15718    query: &str,
15719    path: &Path,
15720    scope: Option<&str>,
15721    limit: usize,
15722    kind: SemanticRelatedKind,
15723    json_output: bool,
15724    compact: bool,
15725    pretty: bool,
15726    terse: bool,
15727    schema: bool,
15728    profile: Option<String>,
15729) -> Result<()> {
15730    let root = lint::resolve_project_root_or_canonical_path(path)?;
15731    write_traversal_graph_store(&root, path, scope)?;
15732    let graph_db = graph_substrate_db_path(&root, scope);
15733    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15734    let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15735    if let Some(recovery) = store.read_only_recovery() {
15736        report
15737            .warnings
15738            .push(graph_db_read_recovery_diagnostic(recovery));
15739    }
15740    if let Some(note) =
15741        profile_preference_note(profile.as_deref(), tsift_local_model::ModelRole::Embed)
15742    {
15743        report.warnings.push(note);
15744    }
15745
15746    if json_output {
15747        println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15748    } else if compact {
15749        for item in &report.items {
15750            println!(
15751                "{:.3}\t{}\t{}\t{}",
15752                item.score, item.kind, item.label, item.handle
15753            );
15754        }
15755        for warning in &report.warnings {
15756            eprintln!("warning: {warning}");
15757        }
15758    } else {
15759        println!(
15760            "Related semantic graph rows for {:?} ({})",
15761            report.query, report.embedding_model
15762        );
15763        for item in &report.items {
15764            println!(
15765                "  {:.3} [{}] {} ({})",
15766                item.score, item.kind, item.label, item.handle
15767            );
15768            if let Some(detail) = &item.detail {
15769                println!("      {}", detail);
15770            }
15771            if let Some(file_path) = &item.file_path {
15772                println!("      file: {}", file_path);
15773            }
15774            println!("      expand: {}", item.expand);
15775        }
15776        for warning in &report.warnings {
15777            eprintln!("warning: {warning}");
15778        }
15779    }
15780
15781    Ok(())
15782}
15783
15784/// Resolve a `--profile` CLI value into a one-line note for the response
15785/// envelope. Records the caller's intent and the resolved profile id, so
15786/// downstream readers see what would have been used even while the actual
15787/// provider seam still falls back to the hash profile (#gctrl2).
15788fn profile_preference_note(
15789    profile: Option<&str>,
15790    role: tsift_local_model::ModelRole,
15791) -> Option<String> {
15792    let preference = tsift_local_model::ProfilePreference::from_cli(profile);
15793    if matches!(preference, tsift_local_model::ProfilePreference::Auto) {
15794        return None;
15795    }
15796    let probe = tsift_local_model::probe_nvidia_smi();
15797    let resolution = tsift_local_model::resolve_profile_preference(&preference, role, &probe);
15798    Some(format!(
15799        "profile preference {} -> {} ({})",
15800        preference.describe(),
15801        resolution.profile.id,
15802        resolution.reason
15803    ))
15804}
15805
15806#[derive(Serialize)]
15807struct SourceLinePreview {
15808    line: usize,
15809    text: String,
15810}
15811
15812#[derive(Serialize)]
15813pub(crate) struct SourceRangePreview {
15814    start: usize,
15815    end: usize,
15816    total_lines: usize,
15817    truncated_before: bool,
15818    truncated_after: bool,
15819}
15820
15821#[derive(Serialize)]
15822struct SourceExpandCommands {
15823    #[serde(skip_serializing_if = "Option::is_none")]
15824    before: Option<String>,
15825    #[serde(skip_serializing_if = "Option::is_none")]
15826    after: Option<String>,
15827    #[serde(skip_serializing_if = "Option::is_none")]
15828    body: Option<String>,
15829    file: String,
15830    #[serde(skip_serializing_if = "Option::is_none")]
15831    markdown_ast: Option<String>,
15832}
15833
15834#[derive(Serialize)]
15835struct SourceSymbolRef {
15836    handle: String,
15837    name: String,
15838    kind: String,
15839    language: String,
15840    file: String,
15841    line: usize,
15842    #[serde(skip_serializing_if = "Option::is_none")]
15843    end_line: Option<usize>,
15844    #[serde(skip_serializing_if = "Option::is_none")]
15845    signature: Option<String>,
15846    #[serde(skip_serializing_if = "Option::is_none")]
15847    span: Option<AstSpanPreview>,
15848    expand: String,
15849}
15850
15851#[derive(Serialize)]
15852struct SourceSummaryRef {
15853    handle: String,
15854    symbol_name: String,
15855    file_path: String,
15856    summary: String,
15857    expand: String,
15858}
15859
15860#[derive(Serialize)]
15861struct SourceReadReport {
15862    handle: String,
15863    root: String,
15864    file: String,
15865    range: SourceRangePreview,
15866    preview: Vec<SourceLinePreview>,
15867    symbols: Vec<SourceSymbolRef>,
15868    summaries: Vec<SourceSummaryRef>,
15869    #[serde(skip_serializing_if = "Option::is_none")]
15870    markdown: Option<SourceReadMarkdownProjection>,
15871    expand: SourceExpandCommands,
15872    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15873    warnings: Vec<String>,
15874}
15875
15876#[derive(Serialize)]
15877struct SourceReadAstExpandCommands {
15878    window: String,
15879    file_window: String,
15880    #[serde(skip_serializing_if = "Option::is_none")]
15881    markdown_ast: Option<String>,
15882}
15883
15884#[derive(Serialize)]
15885struct SourceReadAstReport {
15886    handle: String,
15887    root: String,
15888    file: String,
15889    range: SourceRangePreview,
15890    symbols: Vec<SourceSymbolRef>,
15891    summaries: Vec<SourceSummaryRef>,
15892    #[serde(skip_serializing_if = "Option::is_none")]
15893    markdown: Option<SourceReadMarkdownProjection>,
15894    expand: SourceReadAstExpandCommands,
15895    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15896    warnings: Vec<String>,
15897}
15898
15899#[derive(Serialize)]
15900struct SymbolReadTarget {
15901    handle: String,
15902    name: String,
15903    kind: String,
15904    language: String,
15905    file: String,
15906    line: usize,
15907    #[serde(skip_serializing_if = "Option::is_none")]
15908    end_line: Option<usize>,
15909    #[serde(skip_serializing_if = "Option::is_none")]
15910    signature: Option<String>,
15911    #[serde(skip_serializing_if = "Option::is_none")]
15912    parent_module: Option<String>,
15913    #[serde(skip_serializing_if = "Option::is_none")]
15914    visibility: Option<String>,
15915    #[serde(skip_serializing_if = "Option::is_none")]
15916    span: Option<AstSpanPreview>,
15917}
15918
15919#[derive(Serialize)]
15920struct SymbolReadExpandCommands {
15921    source_window: String,
15922    #[serde(skip_serializing_if = "Option::is_none")]
15923    body: Option<String>,
15924    file: String,
15925    explain: String,
15926    callers: String,
15927    callees: String,
15928    #[serde(skip_serializing_if = "Option::is_none")]
15929    markdown_ast: Option<String>,
15930}
15931
15932#[derive(Serialize)]
15933struct SymbolReadReport {
15934    handle: String,
15935    root: String,
15936    query: String,
15937    symbol: SymbolReadTarget,
15938    range: SourceRangePreview,
15939    body: Vec<SourceLinePreview>,
15940    child_symbols: Vec<SourceSymbolRef>,
15941    summaries: Vec<SourceSummaryRef>,
15942    expand: SymbolReadExpandCommands,
15943    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15944    warnings: Vec<String>,
15945}
15946
15947#[derive(Clone)]
15948pub(crate) struct MarkdownAstRawNode {
15949    handle: String,
15950    span_handle: String,
15951    name: String,
15952    kind: String,
15953    block_kind: String,
15954    node_kind: String,
15955    start_byte: usize,
15956    end_byte: usize,
15957    body_start_byte: Option<usize>,
15958    body_end_byte: Option<usize>,
15959}
15960
15961#[derive(Clone)]
15962pub(crate) struct MarkdownAstProjection {
15963    source_hash: String,
15964    nodes: Vec<MarkdownAstRawNode>,
15965    parse_duration_micros: u128,
15966    cache_hit: bool,
15967}
15968
15969#[derive(Clone)]
15970struct MarkdownAstCacheEntry {
15971    source_hash: String,
15972    nodes: Vec<MarkdownAstRawNode>,
15973    parse_duration_micros: u128,
15974}
15975
15976static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15977    OnceLock::new();
15978
15979#[derive(Serialize, Clone)]
15980struct MarkdownAstNodeMetadata {
15981    #[serde(skip_serializing_if = "Option::is_none")]
15982    heading_level: Option<usize>,
15983    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15984    section_path: Vec<String>,
15985    #[serde(skip_serializing_if = "Option::is_none")]
15986    section_handle: Option<String>,
15987    #[serde(skip_serializing_if = "Option::is_none")]
15988    list_depth: Option<usize>,
15989    #[serde(skip_serializing_if = "Option::is_none")]
15990    list_marker: Option<String>,
15991    #[serde(skip_serializing_if = "Option::is_none")]
15992    list_order: Option<usize>,
15993    #[serde(skip_serializing_if = "Option::is_none")]
15994    fence_language: Option<String>,
15995    #[serde(skip_serializing_if = "Option::is_none")]
15996    fence_marker: Option<String>,
15997    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15998    embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15999}
16000
16001#[derive(Serialize, Clone)]
16002struct MarkdownAstNodeExpand {
16003    source_window: String,
16004    source_body: String,
16005    symbol_read: String,
16006    edit_intents: String,
16007}
16008
16009#[derive(Serialize, Clone)]
16010struct MarkdownAstCacheReport {
16011    source_hash: String,
16012    cache_hit: bool,
16013    parse_duration_micros: u128,
16014    node_count: usize,
16015    section_count: usize,
16016    list_item_count: usize,
16017    code_block_count: usize,
16018}
16019
16020#[derive(Serialize, Clone)]
16021struct MarkdownAstPhaseTiming {
16022    name: String,
16023    duration_micros: u128,
16024    detail: String,
16025}
16026
16027#[derive(Serialize, Clone)]
16028struct MarkdownAstOutlineEntry {
16029    handle: String,
16030    span_handle: String,
16031    name: String,
16032    kind: String,
16033    block_kind: String,
16034    line: usize,
16035    end_line: usize,
16036    #[serde(skip_serializing_if = "Vec::is_empty", default)]
16037    section_path: Vec<String>,
16038    child_count: usize,
16039    expand: String,
16040}
16041
16042#[derive(Serialize, Clone)]
16043struct MarkdownAstProjectionPreview {
16044    mode: String,
16045    total_nodes: usize,
16046    returned_nodes: usize,
16047    omitted_nodes: usize,
16048    selected_node: Option<String>,
16049    cache: MarkdownAstCacheReport,
16050    outline: Vec<MarkdownAstOutlineEntry>,
16051    phase_timings: Vec<MarkdownAstPhaseTiming>,
16052}
16053
16054#[derive(Serialize)]
16055struct SourceReadMarkdownProjection {
16056    handle: String,
16057    mode: String,
16058    total_nodes: usize,
16059    visible_nodes: usize,
16060    outline: Vec<MarkdownAstOutlineEntry>,
16061    expand: String,
16062}
16063
16064#[derive(Serialize, Clone)]
16065struct SourceByteRangePreview {
16066    start: usize,
16067    end: usize,
16068}
16069
16070#[derive(Serialize, Clone)]
16071struct MarkdownAstNode {
16072    handle: String,
16073    span_handle: String,
16074    name: String,
16075    kind: String,
16076    block_kind: String,
16077    node_kind: String,
16078    line: usize,
16079    end_line: usize,
16080    byte_span: SourceByteRangePreview,
16081    #[serde(skip_serializing_if = "Option::is_none")]
16082    body_byte_span: Option<SourceByteRangePreview>,
16083    parent_handle: Option<String>,
16084    #[serde(skip_serializing_if = "Vec::is_empty", default)]
16085    child_handles: Vec<String>,
16086    metadata: MarkdownAstNodeMetadata,
16087    expand: MarkdownAstNodeExpand,
16088}
16089
16090#[derive(Serialize)]
16091struct MarkdownAstExpandCommands {
16092    file: String,
16093    source_read: String,
16094    edit_intents: String,
16095}
16096
16097#[derive(Serialize)]
16098struct MarkdownAstReport {
16099    handle: String,
16100    root: String,
16101    file: String,
16102    range: SourceRangePreview,
16103    projection: MarkdownAstProjectionPreview,
16104    nodes: Vec<MarkdownAstNode>,
16105    expand: MarkdownAstExpandCommands,
16106    #[serde(skip_serializing_if = "Vec::is_empty", default)]
16107    warnings: Vec<String>,
16108}
16109
16110pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
16111    let candidate = if file.is_absolute() {
16112        file.to_path_buf()
16113    } else {
16114        root.join(file)
16115    };
16116    let canonical = candidate
16117        .canonicalize()
16118        .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
16119    if !canonical.is_file() {
16120        bail!("source file is not a regular file: {}", canonical.display());
16121    }
16122    let canonical_root = root
16123        .canonicalize()
16124        .with_context(|| format!("canonicalizing project root {}", root.display()))?;
16125    if !canonical.starts_with(&canonical_root) {
16126        bail!(
16127            "source file {} is outside project root {}",
16128            canonical.display(),
16129            canonical_root.display()
16130        );
16131    }
16132    Ok(canonical)
16133}
16134
16135pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
16136    source_read_window_command(root, file, start, lines)
16137}
16138
16139pub(crate) fn source_read_window_command(
16140    root: &Path,
16141    file: &str,
16142    start: usize,
16143    lines: usize,
16144) -> String {
16145    format!(
16146        "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
16147        shell_quote(file),
16148        shell_quote(&root.to_string_lossy()),
16149        start,
16150        lines
16151    )
16152}
16153
16154pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
16155    format!(
16156        "tsift --envelope source-read {} --path {} --budget normal",
16157        shell_quote(file),
16158        shell_quote(&root.to_string_lossy())
16159    )
16160}
16161
16162pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
16163    format!(
16164        "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
16165        shell_quote(symbol),
16166        shell_quote(&root.to_string_lossy()),
16167        shell_quote(file)
16168    )
16169}
16170
16171fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
16172    format!(
16173        "tsift --envelope explain {} --path {} --budget normal",
16174        shell_quote(symbol),
16175        shell_quote(&root.to_string_lossy())
16176    )
16177}
16178
16179fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
16180    format!(
16181        "tsift graph {} --path {} --{} --json",
16182        shell_quote(symbol),
16183        shell_quote(&root.to_string_lossy()),
16184        relation
16185    )
16186}
16187
16188fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
16189    format!(
16190        "tsift summarize {} --path {} --json",
16191        shell_quote(symbol),
16192        shell_quote(&root.to_string_lossy())
16193    )
16194}
16195
16196pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
16197    let mut command = format!(
16198        "tsift --envelope markdown-ast {} --path {} --budget normal",
16199        shell_quote(file),
16200        shell_quote(&root.to_string_lossy())
16201    );
16202    if let Some(node) = node {
16203        command.push_str(" --node ");
16204        command.push_str(&shell_quote(node));
16205    }
16206    command
16207}
16208
16209fn markdown_edit_intents_command(root: &Path) -> String {
16210    format!(
16211        "tsift --envelope edit-intents --path {} --budget normal",
16212        shell_quote(&root.to_string_lossy())
16213    )
16214}
16215
16216pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
16217    usize::try_from(symbol.line)
16218        .ok()
16219        .and_then(|line| line.checked_add(1))
16220        .unwrap_or(1)
16221}
16222
16223fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
16224    symbol
16225        .end_line
16226        .and_then(|line| usize::try_from(line).ok())
16227        .and_then(|line| line.checked_add(1))
16228}
16229
16230fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
16231    value.and_then(|byte| usize::try_from(byte).ok())
16232}
16233
16234fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
16235    let byte = byte.min(source.len());
16236    source[..byte]
16237        .iter()
16238        .filter(|value| **value == b'\n')
16239        .count()
16240        .saturating_add(1)
16241}
16242
16243fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
16244    source_line_for_byte(source, end_byte.saturating_sub(1))
16245}
16246
16247fn ast_span_handle(
16248    file: &str,
16249    name: &str,
16250    kind: &str,
16251    start_byte: usize,
16252    end_byte: usize,
16253) -> String {
16254    stable_handle(
16255        "span",
16256        &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
16257    )
16258}
16259
16260pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
16261    Some((
16262        symbol_span_byte(symbol.start_byte)?,
16263        symbol_span_byte(symbol.end_byte)?,
16264    ))
16265}
16266
16267pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
16268    Some((
16269        symbol_span_byte(symbol.start_byte)?,
16270        symbol_span_byte(symbol.end_byte)?,
16271    ))
16272}
16273
16274pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
16275    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16276    Some(ast_span_handle(
16277        &symbol.file,
16278        &symbol.name,
16279        &symbol.kind,
16280        start_byte,
16281        end_byte,
16282    ))
16283}
16284
16285fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
16286    left.file == right.file
16287        && left.name == right.name
16288        && left.kind == right.kind
16289        && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
16290}
16291
16292fn stored_symbol_parent_span_handle_in_file(
16293    symbol: &index::StoredSymbol,
16294    symbols: &[&index::StoredSymbol],
16295) -> Option<String> {
16296    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16297    symbols
16298        .iter()
16299        .copied()
16300        .filter(|candidate| {
16301            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16302                return false;
16303            }
16304            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16305            else {
16306                return false;
16307            };
16308            candidate_start <= start_byte && candidate_end >= end_byte
16309        })
16310        .min_by_key(|candidate| {
16311            stored_symbol_span_bounds(candidate)
16312                .map(|(start, end)| end.saturating_sub(start))
16313                .unwrap_or(usize::MAX)
16314        })
16315        .and_then(stored_symbol_span_handle)
16316}
16317
16318fn stored_symbol_child_span_handles_in_file(
16319    symbol: &index::StoredSymbol,
16320    symbols: &[&index::StoredSymbol],
16321    limit: usize,
16322) -> Vec<String> {
16323    let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
16324        return Vec::new();
16325    };
16326    symbols
16327        .iter()
16328        .copied()
16329        .filter(|candidate| {
16330            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16331                return false;
16332            }
16333            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16334            else {
16335                return false;
16336            };
16337            candidate_start >= start_byte && candidate_end <= end_byte
16338        })
16339        .take(limit)
16340        .filter_map(stored_symbol_span_handle)
16341        .collect()
16342}
16343
16344fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
16345    let start = start_byte.min(source.len());
16346    let line_end = source[start..]
16347        .iter()
16348        .position(|value| *value == b'\n')
16349        .map(|pos| start + pos)
16350        .unwrap_or(source.len());
16351    let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
16352    let marker = line.trim_start();
16353    let level = marker.chars().take_while(|ch| *ch == '#').count();
16354    (1..=6).contains(&level).then_some(level)
16355}
16356
16357fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
16358    let start = start_byte.min(source.len());
16359    let line_start = source[..start]
16360        .iter()
16361        .rposition(|value| *value == b'\n')
16362        .map(|pos| pos + 1)
16363        .unwrap_or(0);
16364    source[line_start..start]
16365        .iter()
16366        .map(|byte| match byte {
16367            b'\t' => 4,
16368            b' ' => 1,
16369            _ => 0,
16370        })
16371        .sum::<usize>()
16372        / 2
16373}
16374
16375fn markdown_enclosing_heading_symbols_in_file<'a>(
16376    file: &str,
16377    start_byte: usize,
16378    end_byte: usize,
16379    symbols: &[&'a index::StoredSymbol],
16380) -> Vec<&'a index::StoredSymbol> {
16381    let mut headings = symbols
16382        .iter()
16383        .copied()
16384        .filter(|candidate| candidate.file == file && candidate.kind == "heading")
16385        .filter(|candidate| {
16386            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16387            else {
16388                return false;
16389            };
16390            candidate_start <= start_byte && candidate_end >= end_byte
16391        })
16392        .collect::<Vec<_>>();
16393    headings.sort_by(|left, right| {
16394        stored_symbol_span_bounds(left)
16395            .map(|(start, _)| start)
16396            .unwrap_or(usize::MAX)
16397            .cmp(
16398                &stored_symbol_span_bounds(right)
16399                    .map(|(start, _)| start)
16400                    .unwrap_or(usize::MAX),
16401            )
16402            .then(left.name.cmp(&right.name))
16403    });
16404    headings
16405}
16406
16407fn markdown_stored_symbol_metadata_in_file(
16408    symbol: &index::StoredSymbol,
16409    source: &[u8],
16410    symbols: &[&index::StoredSymbol],
16411) -> Option<MarkdownSpanMetadata> {
16412    if symbol.language != "markdown" {
16413        return None;
16414    }
16415    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16416    let section_symbols =
16417        markdown_enclosing_heading_symbols_in_file(&symbol.file, start_byte, end_byte, symbols);
16418    let section_path = section_symbols
16419        .iter()
16420        .map(|heading| heading.name.clone())
16421        .collect::<Vec<_>>();
16422    let section_handle = section_symbols
16423        .last()
16424        .and_then(|heading| stored_symbol_span_handle(heading));
16425    let heading_level = (symbol.kind == "heading")
16426        .then(|| markdown_heading_level(source, start_byte))
16427        .flatten();
16428    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16429    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16430    let embedded_symbols = if symbol.kind == "code_block" {
16431        markdown_embedded_symbols(
16432            &symbol.file,
16433            source,
16434            symbol_span_byte(symbol.body_start_byte),
16435            symbol_span_byte(symbol.body_end_byte),
16436            fence_language.as_deref(),
16437        )
16438    } else {
16439        Vec::new()
16440    };
16441
16442    (heading_level.is_some()
16443        || !section_path.is_empty()
16444        || section_handle.is_some()
16445        || list_depth.is_some()
16446        || fence_language.is_some()
16447        || !embedded_symbols.is_empty())
16448    .then_some(MarkdownSpanMetadata {
16449        heading_level,
16450        section_path,
16451        section_handle,
16452        list_depth,
16453        fence_language,
16454        embedded_symbols,
16455    })
16456}
16457
16458fn markdown_symbol_hit_metadata(
16459    symbol: &index::SymbolHit,
16460    source: &[u8],
16461    start_byte: usize,
16462) -> Option<MarkdownSpanMetadata> {
16463    if symbol.language != "markdown" {
16464        return None;
16465    }
16466    let heading_level = (symbol.kind == "heading")
16467        .then(|| markdown_heading_level(source, start_byte))
16468        .flatten();
16469    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16470    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16471    let embedded_symbols = if symbol.kind == "code_block" {
16472        markdown_embedded_symbols(
16473            &symbol.file,
16474            source,
16475            symbol_span_byte(symbol.body_start_byte),
16476            symbol_span_byte(symbol.body_end_byte),
16477            fence_language.as_deref(),
16478        )
16479    } else {
16480        Vec::new()
16481    };
16482    (heading_level.is_some()
16483        || list_depth.is_some()
16484        || fence_language.is_some()
16485        || !embedded_symbols.is_empty())
16486    .then_some(MarkdownSpanMetadata {
16487        heading_level,
16488        section_path: Vec::new(),
16489        section_handle: None,
16490        list_depth,
16491        fence_language,
16492        embedded_symbols,
16493    })
16494}
16495
16496fn is_markdown_path(path: &Path) -> bool {
16497    path.extension()
16498        .and_then(|ext| ext.to_str())
16499        .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
16500        .unwrap_or(false)
16501}
16502
16503fn markdown_ast_block_kind(kind: &str) -> String {
16504    match kind {
16505        "heading" => "section",
16506        "code_block" => "fenced_code_block",
16507        "list_item" => "list_item",
16508        other => other,
16509    }
16510    .to_string()
16511}
16512
16513fn markdown_embedded_language_key(language: &str) -> Option<String> {
16514    let key = language
16515        .split_whitespace()
16516        .next()
16517        .unwrap_or("")
16518        .trim()
16519        .trim_start_matches("language-")
16520        .trim_start_matches("lang-")
16521        .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
16522        .to_ascii_lowercase();
16523    (!key.is_empty()).then_some(key)
16524}
16525
16526fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
16527    let key = markdown_embedded_language_key(language)?;
16528    let extension = match key.as_str() {
16529        "rust" => "rs",
16530        "python" => "py",
16531        "typescript" => "ts",
16532        "javascript" => "js",
16533        "kotlin" => "kt",
16534        "gdscript" | "godot" => "gd",
16535        "shell" | "sh" | "zsh" => "bash",
16536        other => other,
16537    };
16538    let lang = graph::Lang::from_extension(extension)?;
16539    (lang.name() != "markdown").then_some(lang)
16540}
16541
16542fn markdown_embedded_ast_span_handle(
16543    file: &str,
16544    language: &str,
16545    name: &str,
16546    kind: &str,
16547    start_byte: usize,
16548    end_byte: usize,
16549) -> String {
16550    stable_handle(
16551        "span",
16552        &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16553    )
16554}
16555
16556fn markdown_embedded_symbols(
16557    file: &str,
16558    source: &[u8],
16559    body_start_byte: Option<usize>,
16560    body_end_byte: Option<usize>,
16561    fence_language: Option<&str>,
16562) -> Vec<MarkdownEmbeddedSymbol> {
16563    let Some(fence_language) = fence_language else {
16564        return Vec::new();
16565    };
16566    let Some(lang) = markdown_embedded_lang(fence_language) else {
16567        return Vec::new();
16568    };
16569    let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16570        return Vec::new();
16571    };
16572    let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16573    else {
16574        return Vec::new();
16575    };
16576    if body.is_empty() {
16577        return Vec::new();
16578    }
16579
16580    let Ok(symbols) = lang.extract_symbols(body) else {
16581        return Vec::new();
16582    };
16583    let language = lang.name().to_string();
16584    symbols
16585        .into_iter()
16586        .map(|symbol| {
16587            let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16588            let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16589            let body_start = symbol
16590                .body_start_byte
16591                .map(|byte| body_start_byte.saturating_add(byte));
16592            let body_end = symbol
16593                .body_end_byte
16594                .map(|byte| body_start_byte.saturating_add(byte));
16595            let start_line = source_line_for_byte(source, start_byte);
16596            let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16597            MarkdownEmbeddedSymbol {
16598                handle: markdown_embedded_ast_span_handle(
16599                    file,
16600                    &language,
16601                    &symbol.name,
16602                    &symbol.kind,
16603                    start_byte,
16604                    end_byte,
16605                ),
16606                name: symbol.name,
16607                kind: symbol.kind,
16608                language: language.clone(),
16609                node_kind: symbol.node_kind,
16610                start_byte,
16611                end_byte,
16612                start_line,
16613                end_line,
16614                body_start_byte: body_start,
16615                body_end_byte: body_end,
16616                body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16617                body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16618            }
16619        })
16620        .collect()
16621}
16622
16623fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16624    let start = start_byte.min(source.len());
16625    let line_start = source[..start]
16626        .iter()
16627        .rposition(|value| *value == b'\n')
16628        .map(|pos| pos + 1)
16629        .unwrap_or(0);
16630    let line_end = source[start..]
16631        .iter()
16632        .position(|value| *value == b'\n')
16633        .map(|pos| start + pos)
16634        .unwrap_or(source.len());
16635    std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16636}
16637
16638fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16639    let line = markdown_source_line(source, start_byte);
16640    let trimmed = line.trim_start();
16641    for marker in ["-", "*", "+"] {
16642        if trimmed
16643            .strip_prefix(marker)
16644            .and_then(|rest| rest.strip_prefix(' '))
16645            .is_some()
16646        {
16647            return (Some(marker.to_string()), None);
16648        }
16649    }
16650
16651    let digit_end = trimmed
16652        .find(|ch: char| !ch.is_ascii_digit())
16653        .unwrap_or(trimmed.len());
16654    let (digits, rest) = trimmed.split_at(digit_end);
16655    if !digits.is_empty() {
16656        for marker in [".", ")"] {
16657            if rest
16658                .strip_prefix(marker)
16659                .and_then(|value| value.strip_prefix(' '))
16660                .is_some()
16661            {
16662                return (
16663                    Some(format!("{digits}{marker}")),
16664                    digits.parse::<usize>().ok(),
16665                );
16666            }
16667        }
16668    }
16669    (None, None)
16670}
16671
16672fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16673    let line = markdown_source_line(source, start_byte);
16674    let trimmed = line.trim_start();
16675    ["```", "~~~"]
16676        .into_iter()
16677        .find(|marker| trimmed.starts_with(marker))
16678        .map(str::to_string)
16679}
16680
16681fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16682    let mut nodes = graph::Lang::Markdown
16683        .extract_symbols(source)
16684        .context("extracting Markdown AST nodes")?
16685        .into_iter()
16686        .map(|symbol| {
16687            let body_start_byte = symbol.body_start_byte;
16688            let body_end_byte = symbol.body_end_byte;
16689            let span_handle = ast_span_handle(
16690                file,
16691                &symbol.name,
16692                &symbol.kind,
16693                symbol.start_byte,
16694                symbol.end_byte,
16695            );
16696            MarkdownAstRawNode {
16697                handle: stable_handle(
16698                    "mdast",
16699                    &format!(
16700                        "{}:{}:{}:{}:{}",
16701                        file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16702                    ),
16703                ),
16704                span_handle,
16705                name: symbol.name,
16706                kind: symbol.kind.clone(),
16707                block_kind: markdown_ast_block_kind(&symbol.kind),
16708                node_kind: symbol.node_kind,
16709                start_byte: symbol.start_byte,
16710                end_byte: symbol.end_byte,
16711                body_start_byte,
16712                body_end_byte,
16713            }
16714        })
16715        .collect::<Vec<_>>();
16716    nodes.sort_by(|left, right| {
16717        left.start_byte
16718            .cmp(&right.start_byte)
16719            .then(left.end_byte.cmp(&right.end_byte))
16720            .then(left.kind.cmp(&right.kind))
16721            .then(left.name.cmp(&right.name))
16722    });
16723    Ok(nodes)
16724}
16725
16726pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16727    let source_hash = blake3::hash(source).to_hex().to_string();
16728    let cache_key = format!("{file}:{source_hash}");
16729    let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16730    if let Some(entry) = cache
16731        .lock()
16732        .expect("markdown ast cache poisoned")
16733        .get(&cache_key)
16734    {
16735        return Ok(MarkdownAstProjection {
16736            source_hash: entry.source_hash.clone(),
16737            nodes: entry.nodes.clone(),
16738            parse_duration_micros: entry.parse_duration_micros,
16739            cache_hit: true,
16740        });
16741    }
16742
16743    let started = Instant::now();
16744    let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16745    let parse_duration_micros = started.elapsed().as_micros();
16746    cache.lock().expect("markdown ast cache poisoned").insert(
16747        cache_key,
16748        MarkdownAstCacheEntry {
16749            source_hash: source_hash.clone(),
16750            nodes: nodes.clone(),
16751            parse_duration_micros,
16752        },
16753    );
16754    Ok(MarkdownAstProjection {
16755        source_hash,
16756        nodes,
16757        parse_duration_micros,
16758        cache_hit: false,
16759    })
16760}
16761
16762fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16763    MarkdownAstCacheReport {
16764        source_hash: projection.source_hash.clone(),
16765        cache_hit: projection.cache_hit,
16766        parse_duration_micros: projection.parse_duration_micros,
16767        node_count: projection.nodes.len(),
16768        section_count: projection
16769            .nodes
16770            .iter()
16771            .filter(|node| node.kind == "heading")
16772            .count(),
16773        list_item_count: projection
16774            .nodes
16775            .iter()
16776            .filter(|node| node.kind == "list_item")
16777            .count(),
16778        code_block_count: projection
16779            .nodes
16780            .iter()
16781            .filter(|node| node.kind == "code_block")
16782            .count(),
16783    }
16784}
16785
16786fn markdown_ast_node_direct_child_count(
16787    node: &MarkdownAstRawNode,
16788    nodes: &[MarkdownAstRawNode],
16789) -> usize {
16790    nodes
16791        .iter()
16792        .filter(|candidate| {
16793            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16794        })
16795        .count()
16796}
16797
16798fn markdown_ast_outline_entry(
16799    root: &Path,
16800    file: &str,
16801    source: &[u8],
16802    nodes: &[MarkdownAstRawNode],
16803    node: &MarkdownAstRawNode,
16804    max_bytes: usize,
16805) -> MarkdownAstOutlineEntry {
16806    let line = source_line_for_byte(source, node.start_byte);
16807    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16808    MarkdownAstOutlineEntry {
16809        handle: node.handle.clone(),
16810        span_handle: node.span_handle.clone(),
16811        name: truncate_for_budget(&node.name, max_bytes),
16812        kind: node.kind.clone(),
16813        block_kind: node.block_kind.clone(),
16814        line,
16815        end_line,
16816        section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16817        child_count: markdown_ast_node_direct_child_count(node, nodes),
16818        expand: markdown_ast_command(root, file, Some(&node.handle)),
16819    }
16820}
16821
16822fn markdown_ast_outline_entries(
16823    root: &Path,
16824    file: &str,
16825    source: &[u8],
16826    nodes: &[MarkdownAstRawNode],
16827    limit: usize,
16828    max_bytes: usize,
16829) -> Vec<MarkdownAstOutlineEntry> {
16830    let mut headings = nodes
16831        .iter()
16832        .filter(|node| node.kind == "heading")
16833        .collect::<Vec<_>>();
16834    let mut blocks = nodes
16835        .iter()
16836        .filter(|node| node.kind != "heading")
16837        .collect::<Vec<_>>();
16838    headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16839    blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16840    headings
16841        .into_iter()
16842        .chain(blocks)
16843        .take(limit)
16844        .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16845        .collect()
16846}
16847
16848fn markdown_ast_node_intersects_lines(
16849    source: &[u8],
16850    node: &MarkdownAstRawNode,
16851    start: usize,
16852    end: usize,
16853) -> bool {
16854    let line = source_line_for_byte(source, node.start_byte);
16855    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16856    line <= end && end_line >= start
16857}
16858
16859fn source_read_markdown_projection(
16860    root: &Path,
16861    file: &str,
16862    source: &[u8],
16863    start: usize,
16864    end: usize,
16865    budget: ResponseBudget,
16866) -> Result<SourceReadMarkdownProjection> {
16867    let projection = markdown_ast_projection(file, source)?;
16868    let visible_nodes = projection
16869        .nodes
16870        .iter()
16871        .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16872        .collect::<Vec<_>>();
16873    let mut outline_nodes = visible_nodes.clone();
16874    outline_nodes.sort_by_key(|node| {
16875        (
16876            node.kind != "heading",
16877            node.start_byte,
16878            node.end_byte,
16879            node.name.as_str(),
16880        )
16881    });
16882    let outline = outline_nodes
16883        .into_iter()
16884        .take(budget.preview_items())
16885        .map(|node| {
16886            markdown_ast_outline_entry(
16887                root,
16888                file,
16889                source,
16890                &projection.nodes,
16891                node,
16892                budget.preview_bytes(),
16893            )
16894        })
16895        .collect::<Vec<_>>();
16896    Ok(SourceReadMarkdownProjection {
16897        handle: stable_handle(
16898            "mdproj",
16899            &format!("{file}:{start}:{end}:{}", projection.source_hash),
16900        ),
16901        mode: "window_outline".to_string(),
16902        total_nodes: projection.nodes.len(),
16903        visible_nodes: visible_nodes.len(),
16904        outline,
16905        expand: markdown_ast_command(root, file, None),
16906    })
16907}
16908
16909fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16910    if parent.handle == child.handle {
16911        return false;
16912    }
16913    parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16914}
16915
16916fn markdown_ast_parent_handle(
16917    node: &MarkdownAstRawNode,
16918    nodes: &[MarkdownAstRawNode],
16919) -> Option<String> {
16920    nodes
16921        .iter()
16922        .filter(|candidate| markdown_ast_contains(candidate, node))
16923        .min_by_key(|candidate| {
16924            (
16925                candidate.end_byte.saturating_sub(candidate.start_byte),
16926                candidate.start_byte,
16927            )
16928        })
16929        .map(|candidate| candidate.handle.clone())
16930}
16931
16932fn markdown_ast_child_handles(
16933    node: &MarkdownAstRawNode,
16934    nodes: &[MarkdownAstRawNode],
16935    limit: usize,
16936) -> Vec<String> {
16937    nodes
16938        .iter()
16939        .filter(|candidate| {
16940            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16941        })
16942        .take(limit)
16943        .map(|candidate| candidate.handle.clone())
16944        .collect()
16945}
16946
16947fn markdown_ast_section_nodes<'a>(
16948    node: &MarkdownAstRawNode,
16949    nodes: &'a [MarkdownAstRawNode],
16950) -> Vec<&'a MarkdownAstRawNode> {
16951    let mut headings = nodes
16952        .iter()
16953        .filter(|candidate| candidate.kind == "heading")
16954        .filter(|candidate| {
16955            candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16956        })
16957        .collect::<Vec<_>>();
16958    headings.sort_by(|left, right| {
16959        left.start_byte
16960            .cmp(&right.start_byte)
16961            .then(left.end_byte.cmp(&right.end_byte))
16962            .then(left.name.cmp(&right.name))
16963    });
16964    headings
16965}
16966
16967fn markdown_ast_node_metadata(
16968    file: &str,
16969    node: &MarkdownAstRawNode,
16970    source: &[u8],
16971    nodes: &[MarkdownAstRawNode],
16972) -> MarkdownAstNodeMetadata {
16973    let section_nodes = markdown_ast_section_nodes(node, nodes);
16974    let section_path = section_nodes
16975        .iter()
16976        .map(|heading| heading.name.clone())
16977        .collect::<Vec<_>>();
16978    let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16979    let heading_level = (node.kind == "heading")
16980        .then(|| markdown_heading_level(source, node.start_byte))
16981        .flatten();
16982    let (list_marker, list_order) = if node.kind == "list_item" {
16983        markdown_list_attributes(source, node.start_byte)
16984    } else {
16985        (None, None)
16986    };
16987    let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16988    let embedded_symbols = if node.kind == "code_block" {
16989        markdown_embedded_symbols(
16990            file,
16991            source,
16992            node.body_start_byte,
16993            node.body_end_byte,
16994            fence_language.as_deref(),
16995        )
16996    } else {
16997        Vec::new()
16998    };
16999    MarkdownAstNodeMetadata {
17000        heading_level,
17001        section_path,
17002        section_handle,
17003        list_depth: (node.kind == "list_item")
17004            .then(|| markdown_list_depth(source, node.start_byte)),
17005        list_marker,
17006        list_order,
17007        fence_language,
17008        fence_marker: (node.kind == "code_block")
17009            .then(|| markdown_fence_marker(source, node.start_byte))
17010            .flatten(),
17011        embedded_symbols,
17012    }
17013}
17014
17015fn markdown_ast_node_expand(
17016    root: &Path,
17017    file: &str,
17018    node: &MarkdownAstRawNode,
17019    source: &[u8],
17020) -> MarkdownAstNodeExpand {
17021    let start_line = source_line_for_byte(source, node.start_byte);
17022    let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
17023    let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
17024    let body_start_line = node
17025        .body_start_byte
17026        .map(|byte| source_line_for_byte(source, byte))
17027        .unwrap_or(start_line);
17028    let body_end_line = node
17029        .body_end_byte
17030        .map(|byte| source_line_for_end_byte(source, byte))
17031        .unwrap_or(end_line)
17032        .max(body_start_line);
17033    let body_line_count = body_end_line
17034        .saturating_sub(body_start_line)
17035        .saturating_add(1)
17036        .max(1);
17037    MarkdownAstNodeExpand {
17038        source_window: source_read_command(root, file, start_line, line_count),
17039        source_body: source_read_command(root, file, body_start_line, body_line_count),
17040        symbol_read: source_symbol_read_command(root, &node.name, file),
17041        edit_intents: markdown_edit_intents_command(root),
17042    }
17043}
17044
17045fn markdown_ast_node(
17046    root: &Path,
17047    file: &str,
17048    node: &MarkdownAstRawNode,
17049    source: &[u8],
17050    nodes: &[MarkdownAstRawNode],
17051    child_limit: usize,
17052) -> MarkdownAstNode {
17053    let line = source_line_for_byte(source, node.start_byte);
17054    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
17055    let body_byte_span = node
17056        .body_start_byte
17057        .zip(node.body_end_byte)
17058        .map(|(start, end)| SourceByteRangePreview { start, end });
17059    MarkdownAstNode {
17060        handle: node.handle.clone(),
17061        span_handle: node.span_handle.clone(),
17062        name: node.name.clone(),
17063        kind: node.kind.clone(),
17064        block_kind: node.block_kind.clone(),
17065        node_kind: node.node_kind.clone(),
17066        line,
17067        end_line,
17068        byte_span: SourceByteRangePreview {
17069            start: node.start_byte,
17070            end: node.end_byte,
17071        },
17072        body_byte_span,
17073        parent_handle: markdown_ast_parent_handle(node, nodes),
17074        child_handles: markdown_ast_child_handles(node, nodes, child_limit),
17075        metadata: markdown_ast_node_metadata(file, node, source, nodes),
17076        expand: markdown_ast_node_expand(root, file, node, source),
17077    }
17078}
17079
17080pub(crate) fn stored_symbol_ast_span(
17081    symbol: &index::StoredSymbol,
17082    source: &[u8],
17083    symbols: &[index::StoredSymbol],
17084    child_limit: usize,
17085) -> Option<AstSpanPreview> {
17086    let file_symbols = symbols.iter().collect::<Vec<_>>();
17087    stored_symbol_ast_span_in_file(symbol, source, &file_symbols, child_limit)
17088}
17089
17090fn stored_symbol_ast_span_in_file(
17091    symbol: &index::StoredSymbol,
17092    source: &[u8],
17093    symbols: &[&index::StoredSymbol],
17094    child_limit: usize,
17095) -> Option<AstSpanPreview> {
17096    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
17097    let node_kind = symbol.node_kind.clone()?;
17098    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17099    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17100    Some(AstSpanPreview {
17101        handle: ast_span_handle(
17102            &symbol.file,
17103            &symbol.name,
17104            &symbol.kind,
17105            start_byte,
17106            end_byte,
17107        ),
17108        node_kind,
17109        start_byte,
17110        end_byte,
17111        start_line: source_line_for_byte(source, start_byte),
17112        end_line: source_line_for_end_byte(source, end_byte),
17113        body_start_byte,
17114        body_end_byte,
17115        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17116        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17117        parent_handle: stored_symbol_parent_span_handle_in_file(symbol, symbols),
17118        child_handles: stored_symbol_child_span_handles_in_file(symbol, symbols, child_limit),
17119        markdown: markdown_stored_symbol_metadata_in_file(symbol, source, symbols),
17120    })
17121}
17122
17123pub(crate) fn symbol_hit_ast_span(
17124    symbol: &index::SymbolHit,
17125    source: &[u8],
17126) -> Option<AstSpanPreview> {
17127    let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
17128    let node_kind = symbol.node_kind.clone()?;
17129    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17130    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17131    Some(AstSpanPreview {
17132        handle: ast_span_handle(
17133            &symbol.file,
17134            &symbol.name,
17135            &symbol.kind,
17136            start_byte,
17137            end_byte,
17138        ),
17139        node_kind,
17140        start_byte,
17141        end_byte,
17142        start_line: source_line_for_byte(source, start_byte),
17143        end_line: source_line_for_end_byte(source, end_byte),
17144        body_start_byte,
17145        body_end_byte,
17146        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17147        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17148        parent_handle: None,
17149        child_handles: Vec::new(),
17150        markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
17151    })
17152}
17153
17154pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
17155    usize::try_from(symbol.line)
17156        .ok()
17157        .and_then(|line| line.checked_add(1))
17158        .unwrap_or(1)
17159}
17160
17161pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
17162    symbol
17163        .end_line
17164        .and_then(|line| usize::try_from(line).ok())
17165        .and_then(|line| line.checked_add(1))
17166}
17167
17168fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
17169    if end == 0 {
17170        return false;
17171    }
17172    let symbol_start = source_symbol_line(symbol);
17173    let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
17174    symbol_start <= end && symbol_end >= start
17175}
17176
17177#[allow(clippy::too_many_arguments)]
17178fn load_source_symbols(
17179    root: &Path,
17180    file_abs: &Path,
17181    file_display: &str,
17182    source: &[u8],
17183    scope: Option<&str>,
17184    start: usize,
17185    end: usize,
17186    limit: usize,
17187    max_bytes: usize,
17188    warnings: &mut Vec<String>,
17189) -> Vec<SourceSymbolRef> {
17190    let target = match resolve_query_index_target(root, file_abs, scope) {
17191        Ok(target) => target,
17192        Err(err) => {
17193            warnings.push(format!("index refs unavailable: {err:#}"));
17194            return Vec::new();
17195        }
17196    };
17197    // Build/refresh the per-package cargo index on demand so source-read delivers
17198    // AST symbol refs for any workspace member — not only members a prior
17199    // graph/search/explain query happened to index. `open_index_db` (the
17200    // search/explain/graph path) already ensures the index is current; before
17201    // this, source-read only checked `db_path.exists()`, so a workspace member
17202    // that had never been queried (≈60% of members here) silently degraded to
17203    // window-only output with an "index refs unavailable" warning even though
17204    // `tsift status` reported the index fresh (#cargoidxcov).
17205    if let Err(err) = ensure_query_index_current(root, &target) {
17206        warnings.push(format!("index refs unavailable: {err:#}"));
17207        return Vec::new();
17208    }
17209    let db_path = target.db_path;
17210    if !db_path.exists() {
17211        warnings.push(format!(
17212            "index refs unavailable: no index found at {}",
17213            db_path.display()
17214        ));
17215        return Vec::new();
17216    }
17217
17218    let db = match index::IndexDb::open_read_only_resilient(&db_path) {
17219        Ok(db) => db,
17220        Err(err) => {
17221            warnings.push(format!("index refs unavailable: {err:#}"));
17222            return Vec::new();
17223        }
17224    };
17225
17226    let file_key = file_abs.to_string_lossy().to_string();
17227    let symbols = match db.symbols_for_file(&file_key) {
17228        Ok(symbols) => symbols,
17229        Err(err) => {
17230            warnings.push(format!("symbol refs unavailable: {err:#}"));
17231            return Vec::new();
17232        }
17233    };
17234
17235    symbols
17236        .iter()
17237        .filter(|symbol| source_symbol_intersects(symbol, start, end))
17238        .take(limit)
17239        .map(|symbol| {
17240            let line = source_symbol_line(symbol);
17241            let end_line = source_symbol_end_line(symbol);
17242            let handle = stable_handle(
17243                "ssym",
17244                &format!("{}:{}:{}", file_display, symbol.name, line),
17245            );
17246            SourceSymbolRef {
17247                handle,
17248                name: truncate_for_budget(&symbol.name, max_bytes),
17249                kind: symbol.kind.clone(),
17250                language: symbol.language.clone(),
17251                file: file_display.to_string(),
17252                line,
17253                end_line,
17254                signature: symbol
17255                    .signature
17256                    .clone()
17257                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
17258                span: stored_symbol_ast_span(symbol, source, &symbols, limit),
17259                expand: source_symbol_read_command(root, &symbol.name, file_display),
17260            }
17261        })
17262        .collect()
17263}
17264
17265fn load_source_summaries(
17266    root: &Path,
17267    file_display: &str,
17268    limit: usize,
17269    max_bytes: usize,
17270    warnings: &mut Vec<String>,
17271) -> Vec<SourceSummaryRef> {
17272    let db_path = root.join(".tsift/summaries.db");
17273    if !db_path.exists() {
17274        return Vec::new();
17275    }
17276    let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
17277        Ok(db) => db,
17278        Err(err) => {
17279            warnings.push(format!("summary refs unavailable: {err:#}"));
17280            return Vec::new();
17281        }
17282    };
17283    let summaries = match db.get_by_file(file_display) {
17284        Ok(summaries) => summaries,
17285        Err(err) => {
17286            warnings.push(format!("summary refs unavailable: {err:#}"));
17287            return Vec::new();
17288        }
17289    };
17290
17291    summaries
17292        .into_iter()
17293        .take(limit)
17294        .map(|summary| SourceSummaryRef {
17295            handle: stable_handle(
17296                "sum",
17297                &format!(
17298                    "{}:{}:{}",
17299                    summary.file_path, summary.symbol_name, summary.id
17300                ),
17301            ),
17302            symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
17303            file_path: summary.file_path,
17304            summary: truncate_for_budget(&summary.summary, max_bytes),
17305            expand: source_summary_expand_command(root, &summary.symbol_name),
17306        })
17307        .collect()
17308}
17309
17310fn cmd_markdown_ast(
17311    file: &Path,
17312    path: &Path,
17313    node: Option<&str>,
17314    format: OutputFormat,
17315    absolute: bool,
17316    budget: ResponseBudget,
17317) -> Result<()> {
17318    let root = lint::resolve_project_root_or_canonical_path(path)?;
17319    let file_abs = resolve_source_file(&root, file)?;
17320    if !is_markdown_path(&file_abs) {
17321        bail!(
17322            "markdown-ast only supports Markdown files (.md/.mdx): {}",
17323            file_abs.display()
17324        );
17325    }
17326    let file_display = if absolute {
17327        file_abs.to_string_lossy().to_string()
17328    } else {
17329        relativize_pathbuf(&file_abs, &root)
17330            .to_string_lossy()
17331            .to_string()
17332    };
17333    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17334    let text = String::from_utf8_lossy(&source);
17335    let total_lines = text.lines().count();
17336    let projection = markdown_ast_projection(&file_display, &source)?;
17337    let raw_nodes = &projection.nodes;
17338    let max_items = budget.preview_items();
17339    let max_bytes = budget.preview_bytes();
17340
17341    let selected_nodes = if let Some(handle) = node {
17342        let matches = raw_nodes
17343            .iter()
17344            .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
17345            .collect::<Vec<_>>();
17346        if matches.is_empty() {
17347            bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
17348        }
17349        matches
17350    } else {
17351        raw_nodes.iter().take(max_items).collect::<Vec<_>>()
17352    };
17353    let nodes = selected_nodes
17354        .into_iter()
17355        .map(|raw| {
17356            let mut node =
17357                markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
17358            node.name = truncate_for_budget(&node.name, max_bytes);
17359            node
17360        })
17361        .collect::<Vec<_>>();
17362    let outline_started = Instant::now();
17363    let outline = markdown_ast_outline_entries(
17364        &root,
17365        &file_display,
17366        &source,
17367        raw_nodes,
17368        max_items,
17369        max_bytes,
17370    );
17371    let outline_duration_micros = outline_started.elapsed().as_micros();
17372    let projection_preview = MarkdownAstProjectionPreview {
17373        mode: if node.is_some() {
17374            "selected_node".to_string()
17375        } else {
17376            "outline_first".to_string()
17377        },
17378        total_nodes: raw_nodes.len(),
17379        returned_nodes: nodes.len(),
17380        omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
17381        selected_node: node.map(str::to_string),
17382        cache: markdown_ast_cache_report(&projection),
17383        outline,
17384        phase_timings: vec![
17385            MarkdownAstPhaseTiming {
17386                name: "parse_extract".to_string(),
17387                duration_micros: projection.parse_duration_micros,
17388                detail: if projection.cache_hit {
17389                    "reused cached tree-sitter Markdown symbol extraction".to_string()
17390                } else {
17391                    "tree-sitter Markdown symbol extraction".to_string()
17392                },
17393            },
17394            MarkdownAstPhaseTiming {
17395                name: "outline_projection".to_string(),
17396                duration_micros: outline_duration_micros,
17397                detail: "outline-first section/block preview construction".to_string(),
17398            },
17399        ],
17400    };
17401    let report = MarkdownAstReport {
17402        handle: stable_handle("mdastrep", &file_display),
17403        root: root.to_string_lossy().to_string(),
17404        file: file_display.clone(),
17405        range: SourceRangePreview {
17406            start: 1,
17407            end: total_lines,
17408            total_lines,
17409            truncated_before: false,
17410            truncated_after: false,
17411        },
17412        projection: projection_preview,
17413        nodes,
17414        expand: MarkdownAstExpandCommands {
17415            file: markdown_ast_command(&root, &file_display, None),
17416            source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
17417            edit_intents: markdown_edit_intents_command(&root),
17418        },
17419        warnings: Vec::new(),
17420    };
17421
17422    if format.json_output {
17423        let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
17424        let mut follow_up = vec![
17425            report.expand.file.clone(),
17426            report.expand.source_read.clone(),
17427            report.expand.edit_intents.clone(),
17428        ];
17429        follow_up.extend(
17430            report
17431                .nodes
17432                .iter()
17433                .map(|node| node.expand.source_window.clone()),
17434        );
17435        print_json_or_envelope(
17436            &report,
17437            &format,
17438            "markdown-ast",
17439            "ast",
17440            ToolEnvelopeSummary {
17441                text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
17442                metrics: vec![
17443                    envelope_metric("nodes", report.nodes.len()),
17444                    envelope_metric("total_nodes", report.projection.total_nodes),
17445                    envelope_metric(
17446                        "parse_duration_micros",
17447                        report.projection.cache.parse_duration_micros,
17448                    ),
17449                    envelope_metric("total_lines", report.range.total_lines),
17450                ],
17451            },
17452            truncated,
17453            follow_up,
17454        )?;
17455    } else if format.compact {
17456        println!(
17457            "markdown-ast {} nodes:{} handle:{}",
17458            report.file,
17459            report.nodes.len(),
17460            report.handle
17461        );
17462        for node in &report.nodes {
17463            println!(
17464                "  {} {} {}:{}-{}",
17465                node.handle, node.kind, node.name, node.line, node.end_line
17466            );
17467        }
17468        if node.is_none() && raw_nodes.len() > report.nodes.len() {
17469            println!("expand: {}", report.expand.file);
17470        }
17471    } else {
17472        println!(
17473            "Markdown AST `{}` nodes {} of {} ({})",
17474            report.file,
17475            report.nodes.len(),
17476            raw_nodes.len(),
17477            report.handle
17478        );
17479        for node in &report.nodes {
17480            println!(
17481                "  {} `{}` {}:{}-{} — {}",
17482                node.handle,
17483                node.name,
17484                node.kind,
17485                node.line,
17486                node.end_line,
17487                node.expand.source_window
17488            );
17489        }
17490        if node.is_none() && raw_nodes.len() > report.nodes.len() {
17491            println!();
17492            println!("Expand:");
17493            println!("  file: {}", report.expand.file);
17494        }
17495    }
17496
17497    Ok(())
17498}
17499
17500#[allow(clippy::too_many_arguments)]
17501fn cmd_source_read(
17502    file: &Path,
17503    path: &Path,
17504    style: SourceReadStyle,
17505    start: usize,
17506    lines: usize,
17507    end: Option<usize>,
17508    scope: Option<&str>,
17509    format: OutputFormat,
17510    absolute: bool,
17511    budget: ResponseBudget,
17512) -> Result<()> {
17513    if start == 0 {
17514        bail!("--start is 1-based and must be greater than zero");
17515    }
17516    if lines == 0 {
17517        bail!("--lines must be greater than zero");
17518    }
17519    if let Some(end) = end
17520        && end < start
17521    {
17522        bail!("--end must be greater than or equal to --start");
17523    }
17524
17525    let root = lint::resolve_project_root_or_canonical_path(path)?;
17526    let file_abs = resolve_source_file(&root, file)?;
17527    let file_display = if absolute {
17528        file_abs.to_string_lossy().to_string()
17529    } else {
17530        relativize_pathbuf(&file_abs, &root)
17531            .to_string_lossy()
17532            .to_string()
17533    };
17534
17535    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17536    let text = String::from_utf8_lossy(&source);
17537    let all_lines: Vec<&str> = text.lines().collect();
17538    let total_lines = all_lines.len();
17539    if total_lines > 0 && start > total_lines {
17540        bail!(
17541            "--start {} is beyond end of {} ({} lines)",
17542            start,
17543            file_display,
17544            total_lines
17545        );
17546    }
17547    let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17548    let end_line = requested_end.min(total_lines);
17549    let mut warnings = Vec::new();
17550    let max_items = budget.preview_items();
17551    let max_bytes = budget.preview_bytes();
17552    if style == SourceReadStyle::Ast {
17553        let symbols = load_source_symbols(
17554            &root,
17555            &file_abs,
17556            &file_display,
17557            &source,
17558            scope,
17559            start,
17560            end_line,
17561            max_items,
17562            max_bytes,
17563            &mut warnings,
17564        );
17565        let summaries =
17566            load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17567        let markdown = if is_markdown_path(&file_abs) {
17568            match source_read_markdown_projection(
17569                &root,
17570                &file_display,
17571                &source,
17572                start,
17573                end_line,
17574                budget,
17575            ) {
17576                Ok(markdown) => Some(markdown),
17577                Err(err) => {
17578                    warnings.push(format!("markdown projection unavailable: {err:#}"));
17579                    None
17580                }
17581            }
17582        } else {
17583            None
17584        };
17585        let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17586        let report = SourceReadAstReport {
17587            handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17588            root: root.to_string_lossy().to_string(),
17589            file: file_display.clone(),
17590            range: SourceRangePreview {
17591                start,
17592                end: end_line,
17593                total_lines,
17594                truncated_before: start > 1,
17595                truncated_after: end_line < total_lines,
17596            },
17597            symbols,
17598            summaries,
17599            markdown,
17600            expand: SourceReadAstExpandCommands {
17601                window: source_read_window_command(&root, &file_display, start, window_lines),
17602                file_window: source_read_window_command(
17603                    &root,
17604                    &file_display,
17605                    1,
17606                    total_lines.max(window_lines),
17607                ),
17608                markdown_ast: is_markdown_path(&file_abs)
17609                    .then(|| markdown_ast_command(&root, &file_display, None)),
17610            },
17611            warnings,
17612        };
17613
17614        if format.json_output {
17615            let truncated = report.range.truncated_before
17616                || report.range.truncated_after
17617                || report.symbols.len() >= max_items
17618                || report.summaries.len() >= max_items;
17619            let follow_up = [
17620                Some(report.expand.window.clone()),
17621                Some(report.expand.file_window.clone()),
17622                report.expand.markdown_ast.clone(),
17623            ]
17624            .into_iter()
17625            .flatten()
17626            .collect::<Vec<_>>();
17627            print_json_or_envelope(
17628                &report,
17629                &format,
17630                "source-read",
17631                "ast",
17632                ToolEnvelopeSummary {
17633                    text: format!(
17634                        "source ast {}:{}-{}",
17635                        report.file, report.range.start, report.range.end
17636                    ),
17637                    metrics: vec![
17638                        envelope_metric("symbols", report.symbols.len()),
17639                        envelope_metric("summaries", report.summaries.len()),
17640                        envelope_metric(
17641                            "markdown_nodes",
17642                            report
17643                                .markdown
17644                                .as_ref()
17645                                .map_or(0, |markdown| markdown.visible_nodes),
17646                        ),
17647                    ],
17648                },
17649                truncated,
17650                follow_up,
17651            )?;
17652        } else if format.compact {
17653            println!(
17654                "source-ast {}:{}-{} / {} handle:{}",
17655                report.file,
17656                report.range.start,
17657                report.range.end,
17658                report.range.total_lines,
17659                report.handle
17660            );
17661            for symbol in &report.symbols {
17662                println!(
17663                    "  {} {}:{} {}",
17664                    symbol.name, symbol.file, symbol.line, symbol.expand
17665                );
17666            }
17667            if !report.summaries.is_empty() {
17668                println!("summaries[{}]", report.summaries.len());
17669            }
17670            for warning in &report.warnings {
17671                eprintln!("warning: {warning}");
17672            }
17673        } else {
17674            println!(
17675                "Source AST `{}` lines {}-{} of {} ({})",
17676                report.file,
17677                report.range.start,
17678                report.range.end,
17679                report.range.total_lines,
17680                report.handle
17681            );
17682            if !report.symbols.is_empty() {
17683                println!();
17684                println!("Symbol refs:");
17685                for symbol in &report.symbols {
17686                    println!(
17687                        "  {} `{}` {}:{} — {}",
17688                        symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17689                    );
17690                }
17691            }
17692            if !report.summaries.is_empty() {
17693                println!();
17694                println!("Summary refs:");
17695                for summary in &report.summaries {
17696                    println!(
17697                        "  {} `{}` — {}",
17698                        summary.handle, summary.symbol_name, summary.expand
17699                    );
17700                }
17701            }
17702            println!();
17703            println!("Expand:");
17704            println!("  window:      {}", report.expand.window);
17705            println!("  file window: {}", report.expand.file_window);
17706            if let Some(markdown_ast) = &report.expand.markdown_ast {
17707                println!("  markdown:    {}", markdown_ast);
17708            }
17709            for warning in &report.warnings {
17710                eprintln!("warning: {warning}");
17711            }
17712        }
17713
17714        return Ok(());
17715    }
17716    let max_bytes = budget.preview_bytes();
17717    let token_cap = budget.body_token_cap();
17718    let (preview, preview_end, body_truncated) = if total_lines == 0 {
17719        (Vec::new(), end_line, false)
17720    } else {
17721        let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17722        (capped.preview, capped.capped_end, capped.was_capped)
17723    };
17724    let effective_end = if body_truncated {
17725        preview_end
17726    } else {
17727        end_line
17728    };
17729
17730    if body_truncated {
17731        warnings.push(format!(
17732            "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17733        ));
17734    }
17735    let symbols = load_source_symbols(
17736        &root,
17737        &file_abs,
17738        &file_display,
17739        &source,
17740        scope,
17741        start,
17742        effective_end,
17743        max_items,
17744        max_bytes,
17745        &mut warnings,
17746    );
17747    let summaries =
17748        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17749    let markdown = if is_markdown_path(&file_abs) {
17750        match source_read_markdown_projection(
17751            &root,
17752            &file_display,
17753            &source,
17754            start,
17755            effective_end,
17756            budget,
17757        ) {
17758            Ok(markdown) => Some(markdown),
17759            Err(err) => {
17760                warnings.push(format!("markdown projection unavailable: {err:#}"));
17761                None
17762            }
17763        }
17764    } else {
17765        None
17766    };
17767
17768    let expand = SourceExpandCommands {
17769        before: (start > 1).then(|| {
17770            let before_start = start.saturating_sub(lines).max(1);
17771            source_read_window_command(&root, &file_display, before_start, start - before_start)
17772        }),
17773        after: (effective_end < total_lines)
17774            .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17775        body: body_truncated.then(|| {
17776            let remaining = end_line.saturating_sub(effective_end);
17777            source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17778        }),
17779        file: source_read_ast_command(&root, &file_display),
17780        markdown_ast: is_markdown_path(&file_abs)
17781            .then(|| markdown_ast_command(&root, &file_display, None)),
17782    };
17783
17784    let report = SourceReadReport {
17785        handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17786        root: root.to_string_lossy().to_string(),
17787        file: file_display,
17788        range: SourceRangePreview {
17789            start,
17790            end: effective_end,
17791            total_lines,
17792            truncated_before: start > 1,
17793            truncated_after: effective_end < total_lines,
17794        },
17795        preview,
17796        symbols,
17797        summaries,
17798        markdown,
17799        expand,
17800        warnings,
17801    };
17802
17803    if format.json_output {
17804        let truncated = report.range.truncated_before || report.range.truncated_after;
17805        let follow_up = [
17806            report.expand.before.clone(),
17807            report.expand.after.clone(),
17808            report.expand.body.clone(),
17809            Some(report.expand.file.clone()),
17810            report.expand.markdown_ast.clone(),
17811        ]
17812        .into_iter()
17813        .flatten()
17814        .collect::<Vec<_>>();
17815        print_json_or_envelope(
17816            &report,
17817            &format,
17818            "source-read",
17819            "window",
17820            ToolEnvelopeSummary {
17821                text: format!(
17822                    "source window {}:{}-{}",
17823                    report.file, report.range.start, report.range.end
17824                ),
17825                metrics: vec![
17826                    envelope_metric("lines", report.preview.len()),
17827                    envelope_metric("symbols", report.symbols.len()),
17828                    envelope_metric("summaries", report.summaries.len()),
17829                    envelope_metric(
17830                        "markdown_nodes",
17831                        report
17832                            .markdown
17833                            .as_ref()
17834                            .map_or(0, |markdown| markdown.visible_nodes),
17835                    ),
17836                ],
17837            },
17838            truncated,
17839            follow_up,
17840        )?;
17841    } else if format.compact {
17842        println!(
17843            "source {}:{}-{} / {} handle:{}",
17844            report.file,
17845            report.range.start,
17846            report.range.end,
17847            report.range.total_lines,
17848            report.handle
17849        );
17850        for line in &report.preview {
17851            println!("{:>5} {}", line.line, line.text);
17852        }
17853        if !report.symbols.is_empty() {
17854            println!("syms[{}]:", report.symbols.len());
17855            for symbol in &report.symbols {
17856                println!("  {} {}:{}", symbol.name, symbol.file, symbol.line);
17857            }
17858        }
17859        if report.range.truncated_before || report.range.truncated_after {
17860            println!("expand: {}", report.expand.file);
17861        }
17862    } else {
17863        println!(
17864            "Source window `{}` lines {}-{} of {} ({})",
17865            report.file,
17866            report.range.start,
17867            report.range.end,
17868            report.range.total_lines,
17869            report.handle
17870        );
17871        for line in &report.preview {
17872            println!("{:>5} | {}", line.line, line.text);
17873        }
17874        if !report.symbols.is_empty() {
17875            println!();
17876            println!("Symbol refs:");
17877            for symbol in &report.symbols {
17878                println!(
17879                    "  {} `{}` {}:{} — {}",
17880                    symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17881                );
17882            }
17883        }
17884        if !report.summaries.is_empty() {
17885            println!();
17886            println!("Summary refs:");
17887            for summary in &report.summaries {
17888                println!(
17889                    "  {} `{}` — {}",
17890                    summary.handle, summary.symbol_name, summary.expand
17891                );
17892            }
17893        }
17894        if report.range.truncated_before || report.range.truncated_after {
17895            println!();
17896            println!("Expand:");
17897            if let Some(before) = &report.expand.before {
17898                println!("  before: {}", before);
17899            }
17900            if let Some(after) = &report.expand.after {
17901                println!("  after: {}", after);
17902            }
17903            println!("  file:   {}", report.expand.file);
17904        }
17905        for warning in &report.warnings {
17906            eprintln!("warning: {warning}");
17907        }
17908    }
17909
17910    Ok(())
17911}
17912
17913#[allow(clippy::too_many_arguments)]
17914fn cmd_symbol_read(
17915    symbol: &str,
17916    file_hint: Option<&Path>,
17917    path: &Path,
17918    scope: Option<&str>,
17919    format: OutputFormat,
17920    absolute: bool,
17921    budget: ResponseBudget,
17922) -> Result<()> {
17923    let root = lint::resolve_project_root_or_canonical_path(path)?;
17924    let hinted_file_abs = file_hint
17925        .map(|file| resolve_source_file(&root, file))
17926        .transpose()?;
17927    let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17928    // Build/refresh the per-package cargo index on demand so symbol-read resolves
17929    // symbols in any workspace member, not only ones a prior graph/search query
17930    // indexed. Previously this checked existence only and bailed with "no index
17931    // found" for never-queried members despite a fresh `tsift status`
17932    // (#cargoidxcov).
17933    let target = resolve_query_index_target(&root, path_hint, scope)?;
17934    ensure_query_index_current(&root, &target)?;
17935    let db_path = target.db_path;
17936    if !db_path.exists() {
17937        bail!(
17938            "index refs unavailable: no index found at {}",
17939            db_path.display()
17940        );
17941    }
17942    let db = index::IndexDb::open_read_only_resilient(&db_path)
17943        .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17944    let search_limit = budget.follow_up_items().max(10);
17945    let hits = db
17946        .symbol_search(symbol, search_limit)
17947        .with_context(|| format!("searching symbols for {symbol:?}"))?;
17948    let selected = hits
17949        .into_iter()
17950        .find(|hit| {
17951            let Some(hinted_file_abs) = &hinted_file_abs else {
17952                return true;
17953            };
17954            resolve_source_file(&root, Path::new(&hit.file))
17955                .map(|hit_file| hit_file == *hinted_file_abs)
17956                .unwrap_or(false)
17957        })
17958        .with_context(|| {
17959            let hint = file_hint
17960                .map(|file| format!(" in {}", file.display()))
17961                .unwrap_or_default();
17962            format!("no indexed symbol matched {symbol:?}{hint}")
17963        })?;
17964
17965    let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17966    let file_display = if absolute {
17967        file_abs.to_string_lossy().to_string()
17968    } else {
17969        relativize_pathbuf(&file_abs, &root)
17970            .to_string_lossy()
17971            .to_string()
17972    };
17973    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17974    let content_hash = blake3::hash(&source).to_hex().to_string();
17975    let text = String::from_utf8_lossy(&source);
17976    let all_lines: Vec<&str> = text.lines().collect();
17977    let total_lines = all_lines.len();
17978    let file_symbols = db
17979        .symbols_for_file(&file_abs.to_string_lossy())
17980        .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17981    let max_items = budget.preview_items();
17982    let max_bytes = budget.preview_bytes();
17983    let selected_start = symbol_hit_line(&selected);
17984    let selected_end = symbol_hit_end_line(&selected)
17985        .unwrap_or(selected_start)
17986        .max(selected_start);
17987    let stored_target = file_symbols.iter().find(|candidate| {
17988        candidate.name == selected.name
17989            && candidate.kind == selected.kind
17990            && source_symbol_line(candidate) == selected_start
17991    });
17992    let target_span = stored_target
17993        .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17994        .or_else(|| symbol_hit_ast_span(&selected, &source));
17995    let target_start = target_span
17996        .as_ref()
17997        .map(|span| span.start_line)
17998        .unwrap_or(selected_start);
17999    let target_end = target_span
18000        .as_ref()
18001        .map(|span| span.end_line)
18002        .or_else(|| stored_target.and_then(source_symbol_end_line))
18003        .unwrap_or(selected_end)
18004        .max(target_start);
18005    let target_bounds = stored_target
18006        .and_then(stored_symbol_span_bounds)
18007        .or_else(|| symbol_hit_span_bounds(&selected));
18008    let target_end = stored_target
18009        .and_then(source_symbol_end_line)
18010        .unwrap_or(target_end)
18011        .max(target_start);
18012    let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
18013    let line_capped_end = target_start
18014        .saturating_add(body_line_budget)
18015        .saturating_sub(1)
18016        .min(target_end)
18017        .min(total_lines.max(target_start));
18018    let token_cap = budget.body_token_cap();
18019    let (body, effective_preview_end, body_truncated) =
18020        if total_lines == 0 || target_start > total_lines {
18021            (Vec::new(), line_capped_end, false)
18022        } else {
18023            let capped = build_token_capped_preview(
18024                &all_lines,
18025                target_start,
18026                line_capped_end,
18027                max_bytes,
18028                token_cap,
18029            );
18030            (capped.preview, capped.capped_end, capped.was_capped)
18031        };
18032    let preview_end = if body_truncated {
18033        effective_preview_end
18034    } else {
18035        line_capped_end
18036    };
18037    let child_symbols = file_symbols
18038        .iter()
18039        .filter(|candidate| {
18040            if let Some((target_start_byte, target_end_byte)) = target_bounds {
18041                let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
18042                else {
18043                    return false;
18044                };
18045                return candidate_start >= target_start_byte
18046                    && candidate_end <= target_end_byte
18047                    && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
18048            }
18049            let line = source_symbol_line(candidate);
18050            line > target_start && line <= target_end
18051        })
18052        .take(max_items)
18053        .map(|symbol| {
18054            let line = source_symbol_line(symbol);
18055            let end_line = source_symbol_end_line(symbol);
18056            SourceSymbolRef {
18057                handle: stable_handle(
18058                    "ssym",
18059                    &format!("{}:{}:{}", file_display, symbol.name, line),
18060                ),
18061                name: truncate_for_budget(&symbol.name, max_bytes),
18062                kind: symbol.kind.clone(),
18063                language: symbol.language.clone(),
18064                file: file_display.clone(),
18065                line,
18066                end_line,
18067                signature: symbol
18068                    .signature
18069                    .clone()
18070                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
18071                span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
18072                expand: source_symbol_read_command(&root, &symbol.name, &file_display),
18073            }
18074        })
18075        .collect::<Vec<_>>();
18076    let mut warnings = Vec::new();
18077    if body_truncated {
18078        warnings.push(format!(
18079            "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
18080        ));
18081    }
18082    let summaries =
18083        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
18084    let symbol_handle = stable_handle(
18085        "sread",
18086        &format!("{}:{}:{}", file_display, selected.name, target_start),
18087    );
18088    let source_lines = preview_end
18089        .saturating_sub(target_start)
18090        .saturating_add(1)
18091        .max(1);
18092    let expand = SymbolReadExpandCommands {
18093        source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
18094        body: body_truncated.then(|| {
18095            let remaining = target_end.saturating_sub(preview_end);
18096            source_read_window_command(&root, &file_display, preview_end + 1, remaining)
18097        }),
18098        file: source_read_ast_command(&root, &file_display),
18099        explain: source_symbol_expand_command(&root, &selected.name),
18100        callers: source_symbol_graph_command(&root, &selected.name, "callers"),
18101        callees: source_symbol_graph_command(&root, &selected.name, "callees"),
18102        markdown_ast: (selected.language == "markdown").then(|| {
18103            markdown_ast_command(
18104                &root,
18105                &file_display,
18106                target_span.as_ref().map(|span| span.handle.as_str()),
18107            )
18108        }),
18109    };
18110    let report = SymbolReadReport {
18111        handle: symbol_handle.clone(),
18112        root: root.to_string_lossy().to_string(),
18113        query: symbol.to_string(),
18114        symbol: SymbolReadTarget {
18115            handle: symbol_handle,
18116            name: selected.name.clone(),
18117            kind: selected.kind.clone(),
18118            language: selected.language.clone(),
18119            file: file_display.clone(),
18120            line: target_start,
18121            end_line: Some(target_end),
18122            signature: stored_target
18123                .and_then(|stored| stored.signature.clone())
18124                .map(|signature| truncate_for_budget(&signature, max_bytes)),
18125            parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
18126            visibility: stored_target.and_then(|stored| stored.visibility.clone()),
18127            span: target_span,
18128        },
18129        range: SourceRangePreview {
18130            start: target_start,
18131            end: preview_end,
18132            total_lines,
18133            truncated_before: false,
18134            truncated_after: preview_end < target_end,
18135        },
18136        body,
18137        child_symbols,
18138        summaries,
18139        expand,
18140        warnings,
18141    };
18142
18143    if format.json_output {
18144        let truncated = report.range.truncated_after
18145            || report.body.iter().any(|line| line.text.len() >= max_bytes)
18146            || report.child_symbols.len() >= max_items;
18147        let follow_up = [
18148            Some(report.expand.source_window.clone()),
18149            report.expand.body.clone(),
18150            Some(report.expand.file.clone()),
18151            Some(report.expand.explain.clone()),
18152            Some(report.expand.callers.clone()),
18153            Some(report.expand.callees.clone()),
18154        ]
18155        .into_iter()
18156        .flatten()
18157        .chain(report.expand.markdown_ast.clone())
18158        .collect::<Vec<_>>();
18159        print_json_or_envelope(
18160            &report,
18161            &format,
18162            "symbol-read",
18163            "symbol",
18164            ToolEnvelopeSummary {
18165                text: format!(
18166                    "symbol {} {}:{}-{}",
18167                    report.symbol.name, report.symbol.file, report.range.start, report.range.end
18168                ),
18169                metrics: vec![
18170                    envelope_metric("body_lines", report.body.len()),
18171                    envelope_metric("child_symbols", report.child_symbols.len()),
18172                    envelope_metric("summaries", report.summaries.len()),
18173                ],
18174            },
18175            truncated,
18176            follow_up,
18177        )?;
18178    } else if format.compact {
18179        println!(
18180            "symbol {} {}:{}-{} handle:{} hash:{}",
18181            report.symbol.name,
18182            report.symbol.file,
18183            report.range.start,
18184            report.range.end,
18185            report.handle,
18186            content_hash
18187        );
18188        for line in &report.body {
18189            println!("{:>5} {}", line.line, line.text);
18190        }
18191        if !report.child_symbols.is_empty() {
18192            println!("children[{}]:", report.child_symbols.len());
18193            for child in &report.child_symbols {
18194                println!("  {} {}:{}", child.name, child.file, child.line);
18195            }
18196        }
18197    } else {
18198        println!(
18199            "Symbol `{}` in `{}` lines {}-{} ({})",
18200            report.symbol.name,
18201            report.symbol.file,
18202            report.range.start,
18203            report.range.end,
18204            report.handle
18205        );
18206        for line in &report.body {
18207            println!("{:>5} | {}", line.line, line.text);
18208        }
18209        if !report.child_symbols.is_empty() {
18210            println!();
18211            println!("Child symbols:");
18212            for child in &report.child_symbols {
18213                println!(
18214                    "  {} `{}` {}:{} — {}",
18215                    child.handle, child.name, child.file, child.line, child.expand
18216                );
18217            }
18218        }
18219        println!();
18220        println!("Expand:");
18221        println!("  source:  {}", report.expand.source_window);
18222        println!("  file:    {}", report.expand.file);
18223        println!("  explain: {}", report.expand.explain);
18224        println!("  callers: {}", report.expand.callers);
18225        println!("  callees: {}", report.expand.callees);
18226        for warning in &report.warnings {
18227            eprintln!("warning: {warning}");
18228        }
18229    }
18230
18231    Ok(())
18232}
18233
18234#[allow(clippy::too_many_arguments)]
18235#[derive(Serialize)]
18236struct ExplainBudgetDefinitionPreview {
18237    handle: String,
18238    #[serde(skip_serializing_if = "Option::is_none")]
18239    tag_alias: Option<String>,
18240    kind: String,
18241    name: String,
18242    file: String,
18243    line: i64,
18244    expand: String,
18245}
18246
18247#[derive(Serialize)]
18248struct ExplainBudgetEdgePreview {
18249    handle: String,
18250    #[serde(skip_serializing_if = "Option::is_none")]
18251    tag_alias: Option<String>,
18252    name: String,
18253    file: String,
18254    line: i64,
18255    expand: String,
18256}
18257
18258#[derive(Serialize)]
18259struct ExplainBudgetCommunityPreview {
18260    size: usize,
18261    members: Vec<String>,
18262}
18263
18264#[derive(Serialize)]
18265struct ExplainBudgetReport {
18266    symbol: String,
18267    max_items: usize,
18268    max_bytes: usize,
18269    definition_total: usize,
18270    callers_total: usize,
18271    callers_truncated_by_limit: bool,
18272    callees_total: usize,
18273    callees_truncated_by_limit: bool,
18274    truncated: bool,
18275    definitions: Vec<ExplainBudgetDefinitionPreview>,
18276    callers: Vec<ExplainBudgetEdgePreview>,
18277    callees: Vec<ExplainBudgetEdgePreview>,
18278    #[serde(skip_serializing_if = "Option::is_none")]
18279    community: Option<ExplainBudgetCommunityPreview>,
18280}
18281
18282#[allow(clippy::too_many_arguments)]
18283pub(crate) fn build_explain_budget_report(
18284    symbol: &str,
18285    _root: &Path,
18286    symbols: &[index::StoredSymbol],
18287    callers: &[index::StoredEdge],
18288    callers_total: usize,
18289    callers_truncated_by_limit: bool,
18290    callees: &[index::StoredEdge],
18291    callees_total: usize,
18292    callees_truncated_by_limit: bool,
18293    community: Option<&graph::Community>,
18294    budget: ResponseBudget,
18295) -> ExplainBudgetReport {
18296    let max_items = budget.preview_items();
18297    let max_bytes = budget.preview_bytes();
18298    let definitions = symbols
18299        .iter()
18300        .take(max_items)
18301        .map(|entry| {
18302            let symbol_ref = build_compact_symbol_ref(
18303                "edef",
18304                &format!(
18305                    "{}:{}:{}:{}",
18306                    entry.kind, entry.name, entry.file, entry.line
18307                ),
18308                &entry.name,
18309                entry.tags.as_deref(),
18310                max_bytes,
18311            );
18312            ExplainBudgetDefinitionPreview {
18313                handle: symbol_ref.handle,
18314                tag_alias: symbol_ref.tag_alias,
18315                kind: entry.kind.clone(),
18316                name: symbol_ref.name,
18317                file: truncate_for_budget(&entry.file, max_bytes),
18318                line: entry.line,
18319                expand: format!(
18320                    "tsift search {} --exact --path {} --limit 20",
18321                    shell_quote(&entry.name),
18322                    shell_quote(&entry.file)
18323                ),
18324            }
18325        })
18326        .collect();
18327    let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
18328        .iter()
18329        .take(max_items)
18330        .map(|entry| {
18331            let symbol_ref = build_compact_symbol_ref(
18332                "ecall",
18333                &format!(
18334                    "{}:{}:{}:{}",
18335                    entry.caller_name, entry.caller_file, entry.call_site_line, symbol
18336                ),
18337                &entry.caller_name,
18338                None,
18339                max_bytes,
18340            );
18341            ExplainBudgetEdgePreview {
18342                handle: symbol_ref.handle,
18343                tag_alias: symbol_ref.tag_alias,
18344                name: symbol_ref.name,
18345                file: truncate_for_budget(&entry.caller_file, max_bytes),
18346                line: entry.call_site_line,
18347                expand: format!(
18348                    "tsift explain {} --path {} --limit 0",
18349                    shell_quote(&entry.caller_name),
18350                    shell_quote(&entry.caller_file)
18351                ),
18352            }
18353        })
18354        .collect();
18355    let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
18356        .iter()
18357        .take(max_items)
18358        .map(|entry| {
18359            let symbol_ref = build_compact_symbol_ref(
18360                "eces",
18361                &format!(
18362                    "{}:{}:{}:{}",
18363                    entry.callee_name, entry.caller_file, entry.call_site_line, symbol
18364                ),
18365                &entry.callee_name,
18366                None,
18367                max_bytes,
18368            );
18369            ExplainBudgetEdgePreview {
18370                handle: symbol_ref.handle,
18371                tag_alias: symbol_ref.tag_alias,
18372                name: symbol_ref.name,
18373                file: truncate_for_budget(&entry.caller_file, max_bytes),
18374                line: entry.call_site_line,
18375                expand: format!(
18376                    "tsift explain {} --path {} --limit 0",
18377                    shell_quote(&entry.callee_name),
18378                    shell_quote(&entry.caller_file)
18379                ),
18380            }
18381        })
18382        .collect();
18383    let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
18384        size: entry.members.len(),
18385        members: entry
18386            .members
18387            .iter()
18388            .take(max_items)
18389            .map(|member| truncate_for_budget(&member.name, max_bytes))
18390            .collect(),
18391    });
18392
18393    ExplainBudgetReport {
18394        symbol: symbol.to_string(),
18395        max_items,
18396        max_bytes,
18397        definition_total: symbols.len(),
18398        callers_total,
18399        callers_truncated_by_limit,
18400        callees_total,
18401        callees_truncated_by_limit,
18402        truncated: symbols.len() > max_items
18403            || callers_total > callers_preview.len()
18404            || callees_total > callees_preview.len()
18405            || community
18406                .map(|entry| entry.members.len() > max_items)
18407                .unwrap_or(false),
18408        definitions,
18409        callers: callers_preview,
18410        callees: callees_preview,
18411        community: community_preview,
18412    }
18413}
18414
18415pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
18416    println!(
18417        "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
18418        shell_quote(&report.symbol),
18419        report.definitions.len(),
18420        report.definition_total,
18421        report.callers.len(),
18422        report.callers_total,
18423        report.callees.len(),
18424        report.callees_total
18425    );
18426    for entry in &report.definitions {
18427        println!(
18428            "def {} {} {}:{} expand:{}",
18429            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18430            entry.kind,
18431            entry.file,
18432            entry.line,
18433            entry.expand
18434        );
18435    }
18436    for entry in &report.callers {
18437        println!(
18438            "caller {} {}:{} expand:{}",
18439            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18440            entry.file,
18441            entry.line,
18442            entry.expand
18443        );
18444    }
18445    for entry in &report.callees {
18446        println!(
18447            "callee {} {}:{} expand:{}",
18448            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18449            entry.file,
18450            entry.line,
18451            entry.expand
18452        );
18453    }
18454    if let Some(community) = &report.community {
18455        println!(
18456            "community size:{} members:{}",
18457            community.size,
18458            community.members.join(", ")
18459        );
18460    }
18461    if report.truncated {
18462        println!(
18463            "budget truncated items:{} bytes:{}",
18464            report.max_items, report.max_bytes
18465        );
18466    }
18467}
18468
18469/// Reconcile the tsift symbol index against the tagpath `.naming/index.json`
18470/// source set and report files covered by one but not the other.
18471///
18472/// Today silent recall loss happens when tagpath's `[exclude]` / `extends`
18473/// chain or its hard-coded `SKIP_DIRS` skip files or languages that tsift
18474/// still indexes — the tsift symbols in those files cannot resolve a
18475/// `tagpath_handle` even with a fresh tagpath index. This audit surfaces
18476/// the diff so operators can decide whether to broaden the tagpath walk,
18477/// add an `[exclude]` to tsift, or accept the gap.
18478const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
18479    ".git",
18480    "node_modules",
18481    "target",
18482    "__pycache__",
18483    ".venv",
18484    "vendor",
18485];
18486
18487const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
18488    "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
18489    "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
18490    "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
18491    "sh", "bash", "zsh", "sql", "css", "tsx", "gd",
18492];
18493
18494pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
18495    let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
18496        .iter()
18497        .map(|ext| (*ext).to_string())
18498        .collect::<BTreeSet<_>>();
18499
18500    let config_path = root.join(".naming.toml");
18501    if !config_path.exists() {
18502        return extensions;
18503    }
18504
18505    match tagpath::config::resolve(&config_path) {
18506        Ok(config) => {
18507            if let Some(grammars) = config.grammars {
18508                for grammar in grammars.languages.values() {
18509                    for ext in &grammar.extensions {
18510                        if let Some(normalized) = normalize_extension(ext) {
18511                            extensions.insert(normalized);
18512                        }
18513                    }
18514                }
18515            }
18516        }
18517        Err(err) => {
18518            eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18519        }
18520    }
18521    extensions
18522}
18523
18524pub(crate) fn tagpath_audit_policy_hints(
18525    rel_path: &str,
18526    supported_extensions: &BTreeSet<String>,
18527) -> Vec<String> {
18528    let path = Path::new(rel_path);
18529    let mut hints = BTreeSet::new();
18530    if let Some(parent) = path.parent() {
18531        for component in parent.components() {
18532            if let std::path::Component::Normal(name) = component {
18533                let name = name.to_string_lossy();
18534                if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18535                    hints.insert(format!("skip_dir:{name}"));
18536                }
18537            }
18538        }
18539    }
18540    if path
18541        .extension()
18542        .and_then(|ext| ext.to_str())
18543        .and_then(normalize_extension)
18544        .is_some_and(|ext| !supported_extensions.contains(&ext))
18545    {
18546        hints.insert("extension_unsupported".to_string());
18547    }
18548    hints.into_iter().collect()
18549}
18550
18551fn normalize_extension(ext: &str) -> Option<String> {
18552    let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18553    if normalized.is_empty() {
18554        None
18555    } else {
18556        Some(normalized)
18557    }
18558}
18559
18560pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18561    match status {
18562        diff_digest::DiffDigestFileStatus::Added => "added",
18563        diff_digest::DiffDigestFileStatus::Modified => "modified",
18564        diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18565    }
18566}
18567
18568pub(crate) fn diff_digest_summary_label(
18569    state: diff_digest::DiffDigestSummaryState,
18570) -> &'static str {
18571    match state {
18572        diff_digest::DiffDigestSummaryState::Current => "current",
18573        diff_digest::DiffDigestSummaryState::Stale => "stale",
18574        diff_digest::DiffDigestSummaryState::Missing => "missing",
18575        diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18576    }
18577}
18578
18579fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18580    match state {
18581        test_digest::TestDigestSummaryState::Current => "current",
18582        test_digest::TestDigestSummaryState::Stale => "stale",
18583        test_digest::TestDigestSummaryState::Missing => "missing",
18584        test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18585    }
18586}
18587
18588fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18589    match state {
18590        log_digest::LogDigestSummaryState::Current => "current",
18591        log_digest::LogDigestSummaryState::Stale => "stale",
18592        log_digest::LogDigestSummaryState::Missing => "missing",
18593        log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18594    }
18595}
18596
18597pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18598    match mode {
18599        diff_digest::DiffDigestMode::WorkingTree => "worktree",
18600        diff_digest::DiffDigestMode::Cached => "cached",
18601        diff_digest::DiffDigestMode::Revision => "revision",
18602    }
18603}
18604
18605pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18606    match (&report.mode, &report.revision) {
18607        (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18608        (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18609        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18610            format!("revision {revision}")
18611        }
18612        (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18613    }
18614}
18615
18616pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18617    match (&report.mode, &report.revision) {
18618        (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18619        (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18620        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18621            format!("No diff found for revision {revision}.")
18622        }
18623        (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18624    }
18625}
18626
18627fn cmd_impact(
18628    path: &Path,
18629    cached: bool,
18630    revision: Option<&str>,
18631    scope: Option<&str>,
18632    limit: usize,
18633    format: OutputFormat,
18634) -> Result<()> {
18635    let report = impact::compute(
18636        path,
18637        impact::ImpactOptions {
18638            cached,
18639            revision,
18640            scope,
18641            limit,
18642        },
18643    )?;
18644    if format.json_output {
18645        println!(
18646            "{}",
18647            to_json_schema(
18648                &report,
18649                format.pretty,
18650                format.terse,
18651                format.ultra_terse,
18652                format.schema
18653            )?
18654        );
18655        return Ok(());
18656    }
18657
18658    if format.compact {
18659        println!(
18660            "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18661            diff_digest_mode_label(report.mode),
18662            report.changed_files.len(),
18663            report.changed_symbols.len(),
18664            report.affected_tests.len(),
18665            report.affected_tests_total
18666        );
18667        for target in &report.affected_tests {
18668            println!(
18669                "{} reasons:{} command:{}",
18670                target.path,
18671                target.reasons.len(),
18672                target.commands.join(" && ")
18673            );
18674        }
18675        for warning in &report.warnings {
18676            println!("warning {warning}");
18677        }
18678        return Ok(());
18679    }
18680
18681    println!("Impact ({})", diff_digest_mode_label(report.mode));
18682    println!("  changed files:          {}", report.changed_files.len());
18683    println!("  changed symbols:        {}", report.changed_symbols.len());
18684    println!(
18685        "  affected tests:         {}/{}",
18686        report.affected_tests.len(),
18687        report.affected_tests_total
18688    );
18689    for target in &report.affected_tests {
18690        println!();
18691        println!("{}", target.path);
18692        for reason in &target.reasons {
18693            println!("  - {reason}");
18694        }
18695        if !target.symbols.is_empty() {
18696            println!("  symbols: {}", target.symbols.join(", "));
18697        }
18698        for command in &target.commands {
18699            println!("  run: {}", command);
18700        }
18701    }
18702    for warning in &report.warnings {
18703        println!("warning: {warning}");
18704    }
18705    Ok(())
18706}
18707
18708pub(crate) fn render_test_digest_from_input(
18709    path: &Path,
18710    input: &str,
18711    runner: Option<&str>,
18712    format: OutputFormat,
18713) -> Result<()> {
18714    let report = test_digest::compute(path, input, runner)?;
18715    if format.json_output {
18716        println!(
18717            "{}",
18718            to_json_schema(
18719                &report,
18720                format.pretty,
18721                format.terse,
18722                format.ultra_terse,
18723                format.schema
18724            )?
18725        );
18726        return Ok(());
18727    }
18728
18729    if report.failure_groups.is_empty() {
18730        println!("No failures detected (runner: {}).", report.runner);
18731        for warning in &report.warnings {
18732            println!("warning: {warning}");
18733        }
18734        return Ok(());
18735    }
18736
18737    if format.compact {
18738        println!(
18739            "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18740            report.runner,
18741            report.failures,
18742            report.grouped_failures,
18743            report.counts.passed.unwrap_or(0),
18744            report.counts.failed.unwrap_or(report.grouped_failures),
18745            report.counts.skipped.unwrap_or(0),
18746        );
18747        for failure in &report.failure_groups {
18748            let tests = truncate_for_compact(&failure.tests.join(","), 60);
18749            let location = match (&failure.path, failure.line) {
18750                (Some(path), Some(line)) => format!("{path}:{line}"),
18751                (Some(path), None) => path.clone(),
18752                _ => "-".to_string(),
18753            };
18754            println!(
18755                "{} tests:{} count:{} summaries:{} msg:{}",
18756                location,
18757                tests,
18758                failure.occurrences,
18759                test_digest_summary_label(failure.summary_state),
18760                truncate_for_compact(&failure.message, 80)
18761            );
18762        }
18763        for warning in &report.warnings {
18764            println!("warning: {warning}");
18765        }
18766        return Ok(());
18767    }
18768
18769    println!("Test digest ({})", report.runner);
18770    println!("  failures:        {}", report.failures);
18771    println!("  failure groups:  {}", report.grouped_failures);
18772    if let Some(passed) = report.counts.passed {
18773        println!("  passed:          {}", passed);
18774    }
18775    if let Some(failed) = report.counts.failed {
18776        println!("  failed:          {}", failed);
18777    }
18778    if let Some(skipped) = report.counts.skipped {
18779        println!("  skipped:         {}", skipped);
18780    }
18781
18782    for failure in &report.failure_groups {
18783        println!();
18784        match (&failure.path, failure.line, failure.column) {
18785            (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18786            (Some(path), Some(line), None) => println!("{path}:{line}"),
18787            (Some(path), None, _) => println!("{path}"),
18788            (None, _, _) => println!("(no file anchor)"),
18789        }
18790        println!("  tests: {}", failure.tests.join(", "));
18791        println!("  occurrences: {}", failure.occurrences);
18792        println!("  message: {}", failure.message);
18793        println!(
18794            "  cached summaries: {}",
18795            test_digest_summary_label(failure.summary_state)
18796        );
18797        for summary in &failure.current_summaries {
18798            println!(
18799                "    - {}: {}",
18800                summary.symbol,
18801                truncate_for_compact(&summary.summary, 160)
18802            );
18803        }
18804    }
18805    for warning in &report.warnings {
18806        println!("warning: {warning}");
18807    }
18808    Ok(())
18809}
18810
18811#[derive(Clone, Serialize, Deserialize)]
18812struct DispatchTraceSummary {
18813    backlog: usize,
18814    job_packet: usize,
18815    worker_result: usize,
18816    worker_context: usize,
18817    source_handle: usize,
18818    semantic_rows: usize,
18819}
18820
18821#[derive(Clone, Serialize, Deserialize)]
18822struct DispatchTraceReport {
18823    contract_version: String,
18824    root: String,
18825    #[serde(skip_serializing_if = "Option::is_none")]
18826    scope: Option<String>,
18827    targets: Vec<String>,
18828    projection_freshness: GraphDbFreshnessReport,
18829    projection_hashes: Vec<String>,
18830    evidence_packet_ids: Vec<String>,
18831    shared_preparation: ConflictMatrixSharedPreparationSummary,
18832    worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18833    worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18834    summary: DispatchTraceSummary,
18835    nodes: Vec<SubstrateTerseGraphNode>,
18836    edges: Vec<SubstrateTerseGraphEdge>,
18837    conflict_matrix_decisions: Vec<String>,
18838    replay_commands: Vec<String>,
18839    repair_commands: Vec<String>,
18840    truncated: bool,
18841    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18842    warnings: Vec<String>,
18843}
18844
18845fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18846    matches!(
18847        kind,
18848        "session"
18849            | "backlog"
18850            | "job_packet"
18851            | "worker_result"
18852            | "worker_context"
18853            | "source_handle"
18854            | "semantic_concept"
18855            | "semantic_entity"
18856            | "file"
18857            | "symbol"
18858            | "route"
18859    )
18860}
18861
18862fn dispatch_trace_kind_rank(kind: &str) -> usize {
18863    match kind {
18864        "backlog" => 0,
18865        "job_packet" => 1,
18866        "worker_result" => 2,
18867        "worker_context" => 3,
18868        "source_handle" => 4,
18869        "file" => 5,
18870        "symbol" => 6,
18871        "route" => 7,
18872        "semantic_concept" => 8,
18873        "semantic_entity" => 9,
18874        "session" => 10,
18875        _ => 99,
18876    }
18877}
18878
18879fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18880    DispatchTraceSummary {
18881        backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18882        job_packet: nodes
18883            .iter()
18884            .filter(|node| node.kind == "job_packet")
18885            .count(),
18886        worker_result: nodes
18887            .iter()
18888            .filter(|node| node.kind == "worker_result")
18889            .count(),
18890        worker_context: nodes
18891            .iter()
18892            .filter(|node| node.kind == "worker_context")
18893            .count(),
18894        source_handle: nodes
18895            .iter()
18896            .filter(|node| node.kind == "source_handle")
18897            .count(),
18898        semantic_rows: nodes
18899            .iter()
18900            .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18901            .count(),
18902    }
18903}
18904
18905fn dispatch_trace_shared_preparation_summary(
18906    graph_nodes: &[SubstrateGraphNode],
18907    graph_edges: &[SubstrateGraphEdge],
18908    conflict: &ConflictMatrixReport,
18909) -> ConflictMatrixSharedPreparationSummary {
18910    ConflictMatrixSharedPreparationSummary {
18911        evidence_cache_status: conflict
18912            .inputs
18913            .shared_preparation
18914            .evidence_cache_status
18915            .clone(),
18916        graph_nodes: graph_nodes.len(),
18917        graph_edges: graph_edges.len(),
18918        evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18919        source_handles: conflict
18920            .candidates
18921            .iter()
18922            .map(|candidate| candidate.source_handles.len())
18923            .sum(),
18924        worker_context: conflict
18925            .candidates
18926            .iter()
18927            .map(|candidate| candidate.worker_context_handles.len())
18928            .sum(),
18929        worker_results: conflict
18930            .candidates
18931            .iter()
18932            .map(|candidate| candidate.worker_feedback.total)
18933            .sum(),
18934        semantic_rows: conflict
18935            .candidates
18936            .iter()
18937            .map(|candidate| candidate.semantic_related.len())
18938            .sum(),
18939        dispatch_trace_snapshot_nodes: graph_nodes.len(),
18940        dispatch_trace_snapshot_edges: graph_edges.len(),
18941    }
18942}
18943
18944fn dispatch_trace_collect_ids(
18945    targets: &[String],
18946    candidates: &[ConflictMatrixCandidate],
18947    graph_nodes: &[SubstrateGraphNode],
18948    graph_edges: &[SubstrateGraphEdge],
18949    depth: usize,
18950    limit: usize,
18951) -> (BTreeSet<String>, bool) {
18952    let target_refs = targets
18953        .iter()
18954        .map(|target| target.trim_start_matches('#').to_string())
18955        .collect::<BTreeSet<_>>();
18956    let mut ids = BTreeSet::new();
18957    for candidate in candidates {
18958        ids.insert(candidate.target_node_id.clone());
18959        for source in &candidate.source_handles {
18960            ids.insert(source.handle.clone());
18961        }
18962        for handle in &candidate.worker_context_handles {
18963            ids.insert(handle.clone());
18964        }
18965        for semantic in &candidate.semantic_related {
18966            ids.insert(semantic.handle.clone());
18967        }
18968    }
18969    for node in graph_nodes {
18970        if !dispatch_trace_allowed_node_kind(&node.kind) {
18971            continue;
18972        }
18973        if node
18974            .properties
18975            .get("ref_id")
18976            .is_some_and(|ref_id| target_refs.contains(ref_id))
18977        {
18978            ids.insert(node.id.clone());
18979        }
18980    }
18981
18982    let node_by_id = graph_nodes
18983        .iter()
18984        .map(|node| (node.id.as_str(), node))
18985        .collect::<BTreeMap<_, _>>();
18986    let max_nodes = if limit == 0 {
18987        usize::MAX
18988    } else {
18989        limit
18990            .saturating_mul(targets.len().max(1))
18991            .saturating_mul(12)
18992            .max(64)
18993    };
18994    let mut truncated = false;
18995    for _ in 0..depth.max(1) {
18996        let before = ids.len();
18997        let current_ids = ids.clone();
18998        for edge in graph_edges {
18999            if ids.len() >= max_nodes {
19000                truncated = true;
19001                break;
19002            }
19003            let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
19004            if !touches {
19005                continue;
19006            }
19007            for endpoint in [&edge.from_id, &edge.to_id] {
19008                let Some(node) = node_by_id.get(endpoint.as_str()) else {
19009                    continue;
19010                };
19011                if dispatch_trace_allowed_node_kind(&node.kind) {
19012                    ids.insert(endpoint.clone());
19013                }
19014            }
19015        }
19016        if ids.len() == before || truncated {
19017            break;
19018        }
19019    }
19020    (ids, truncated)
19021}
19022
19023#[allow(clippy::too_many_arguments)]
19024fn build_dispatch_trace_report_from_conflict_snapshot(
19025    root: &Path,
19026    scope: Option<&str>,
19027    conflict: ConflictMatrixReport,
19028    graph_nodes: Vec<SubstrateGraphNode>,
19029    graph_edges: Vec<SubstrateGraphEdge>,
19030    depth: usize,
19031    limit: usize,
19032    extra_warnings: Vec<String>,
19033) -> Result<DispatchTraceReport> {
19034    let shared_preparation =
19035        dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
19036    let (ids, truncated) = dispatch_trace_collect_ids(
19037        &conflict.targets,
19038        &conflict.candidates,
19039        &graph_nodes,
19040        &graph_edges,
19041        depth,
19042        limit,
19043    );
19044    let mut nodes = graph_nodes
19045        .into_iter()
19046        .filter(|node| ids.contains(&node.id))
19047        .collect::<Vec<_>>();
19048    nodes.sort_by(|left, right| {
19049        dispatch_trace_kind_rank(&left.kind)
19050            .cmp(&dispatch_trace_kind_rank(&right.kind))
19051            .then(left.id.cmp(&right.id))
19052    });
19053    let node_ids = nodes
19054        .iter()
19055        .map(|node| node.id.as_str())
19056        .collect::<BTreeSet<_>>();
19057    let mut edges = graph_edges
19058        .into_iter()
19059        .filter(|edge| {
19060            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
19061        })
19062        .collect::<Vec<_>>();
19063    edges.sort_by(|left, right| {
19064        left.from_id
19065            .cmp(&right.from_id)
19066            .then(left.kind.cmp(&right.kind))
19067            .then(left.to_id.cmp(&right.to_id))
19068    });
19069    let mut warnings = conflict.warnings;
19070    warnings.extend(extra_warnings);
19071
19072    Ok(DispatchTraceReport {
19073        contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
19074        root: conflict.root,
19075        scope: conflict.scope,
19076        targets: conflict.targets,
19077        projection_freshness: conflict.orchestration.projection_freshness,
19078        projection_hashes: conflict.orchestration.projection_hashes,
19079        evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
19080        shared_preparation,
19081        worker_prompt_packets: conflict.worker_prompt_packets,
19082        worker_feedback: conflict
19083            .candidates
19084            .iter()
19085            .map(|candidate| candidate.worker_feedback.clone())
19086            .collect(),
19087        summary: dispatch_trace_summary(&nodes),
19088        nodes: nodes.into_iter().map(Into::into).collect(),
19089        edges: edges.into_iter().map(Into::into).collect(),
19090        conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
19091        replay_commands: conflict.next_commands,
19092        repair_commands: graph_db_repair_commands(root, scope),
19093        truncated,
19094        warnings,
19095    })
19096}
19097
19098fn build_dispatch_trace_report(
19099    path: &Path,
19100    scope: Option<&str>,
19101    raw_targets: &[String],
19102    depth: usize,
19103    limit: usize,
19104    impact_limit: usize,
19105) -> Result<DispatchTraceReport> {
19106    let root = lint::resolve_project_root_or_canonical_path(path)?;
19107    let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
19108    if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
19109        write_traversal_graph_store(&root, path, scope)
19110            .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19111    }
19112    let graph_db = graph_substrate_db_path(&root, scope);
19113    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19114        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19115    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19116    let extra_warnings = store
19117        .read_only_recovery()
19118        .map(graph_db_read_recovery_diagnostic)
19119        .into_iter()
19120        .collect::<Vec<_>>();
19121    let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
19122    let graph_prepared = prepare_conflict_matrix_graph_orchestration(
19123        &root,
19124        scope,
19125        "sqlite",
19126        raw_targets,
19127        &prepared,
19128        depth,
19129        limit,
19130        &store,
19131        freshness.clone(),
19132    )?;
19133    let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
19134        &prepared.preparation_cache.source_watermark,
19135        &prepared.preparation_cache.document_watermark,
19136        &prepared.preparation_cache.staged_diff_watermark,
19137        &[
19138            &format!("targets:{}", raw_targets.join(",")),
19139            &format!("depth:{depth}"),
19140            &format!("limit:{limit}"),
19141        ],
19142    );
19143    if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
19144        &root,
19145        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19146        &dt_cache_key,
19147    ) {
19148        return Ok(cached_report);
19149    }
19150    let conflict = build_conflict_matrix_report_from_prepared_graph(
19151        &root,
19152        path,
19153        scope,
19154        depth,
19155        limit,
19156        impact_limit,
19157        freshness,
19158        extra_warnings.clone(),
19159        &prepared,
19160        &graph_prepared,
19161    )?;
19162    let report = build_dispatch_trace_report_from_conflict_snapshot(
19163        &root,
19164        scope,
19165        conflict,
19166        graph_prepared.graph.nodes,
19167        graph_prepared.graph.edges,
19168        depth,
19169        limit,
19170        extra_warnings,
19171    )?;
19172    cycle_packet_cache::cycle_packet_write_cache(
19173        &root,
19174        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19175        &dt_cache_key,
19176        &report,
19177    );
19178    Ok(report)
19179}
19180
19181fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
19182    let json = serde_json::to_string(report)?.replace("</", "<\\/");
19183    let mut html = String::new();
19184    html.push_str(
19185        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
19186    );
19187    html.push_str(
19188        r#"<style>
19189:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
19190@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
19191*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.4}.page{max-width:1280px;margin:0 auto;padding:20px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:14px}.top h1{font-size:22px;margin:0}.meta{color:var(--muted);font-size:13px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:14px}.panel,.side{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.side{padding:14px;overflow:auto;max-height:720px}.side h2{font-size:15px;margin:12px 0 8px}.side h2:first-child{margin-top:0}.list{display:grid;gap:8px}.row{border:1px solid var(--line);border-radius:6px;padding:8px}.kind{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}.label{font-weight:650;overflow-wrap:anywhere}.handle,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);overflow-wrap:anywhere}svg{width:100%;height:680px;display:block}.edge{stroke:var(--edge);stroke-width:1.4;opacity:.72}.node{stroke:var(--panel);stroke-width:2}.node-label{font-size:12px;paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round;fill:var(--text)}@media(max-width:900px){.top{display:block}.layout{grid-template-columns:1fr}.side{max-height:none}svg{height:560px}}
19192</style>"#,
19193    );
19194    html.push_str("</head><body><div class=\"page\">");
19195    html.push_str(&format!(
19196        "<header class=\"top\"><div><h1>tsift dispatch trace</h1><div class=\"meta\">targets <code>{}</code> | evidence <code>{}</code> | nodes <code>{}</code> | worker_prompt_packets <code>{}</code></div></div><div class=\"meta\"><code>{}</code></div></header>",
19197        html_escape(&report.targets.join(", ")),
19198        report.evidence_packet_ids.len(),
19199        report.nodes.len(),
19200        report.worker_prompt_packets.len(),
19201        html_escape(&report.contract_version)
19202    ));
19203    html.push_str(
19204        r#"<main class="layout"><section class="panel"><svg id="graph-canvas" role="img" aria-label="Dispatch trace graph"></svg></section><aside class="side"><h2>Worker Prompt Packets</h2><div id="packets" class="list"></div><h2>Worker Feedback</h2><div id="feedback" class="list"></div><h2>Nodes</h2><div id="nodes" class="list"></div></aside></main>"#,
19205    );
19206    html.push_str("<script id=\"trace-data\" type=\"application/json\">");
19207    html.push_str(&json);
19208    html.push_str(
19209        r##"</script><script>
19210const report = JSON.parse(document.getElementById("trace-data").textContent);
19211const svg = document.getElementById("graph-canvas");
19212const nodeList = document.getElementById("nodes");
19213const packets = document.getElementById("packets");
19214const feedback = document.getElementById("feedback");
19215const nodes = report.nodes.map((node, index) => ({...node, index}));
19216const nodeById = new Map(nodes.map(node => [node.id, node]));
19217const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
19218const colorByKind = new Map([["backlog","#dc2626"],["job_packet","#ea580c"],["worker_result","#15803d"],["worker_context","#475569"],["source_handle","#64748b"],["semantic_concept","#9a3412"],["semantic_entity","#b45309"],["file","#2563eb"],["symbol","#16a34a"],["route","#7c3aed"],["session","#0891b2"]]);
19219function color(kind){return colorByKind.get(kind)||"#6b7280";}
19220function text(value){return value == null ? "" : String(value);}
19221function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));}
19222function layout(){
19223  const rect = svg.getBoundingClientRect();
19224  const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
19225  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
19226  const counts = new Map();
19227  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
19228  const offsets = new Map();
19229  for (const node of nodes) {
19230    const group = kinds.indexOf(node.kind);
19231    const index = offsets.get(node.kind) || 0;
19232    offsets.set(node.kind, index + 1);
19233    const total = counts.get(node.kind) || 1;
19234    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
19235    const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
19236    node.x = cx + Math.cos(angle) * ring;
19237    node.y = cy + Math.sin(angle) * ring;
19238  }
19239}
19240function draw(){
19241  svg.innerHTML = "";
19242  for (const edge of edges) {
19243    const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
19244    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
19245    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
19246    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
19247    line.setAttribute("class", "edge");
19248    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
19249    svg.appendChild(line);
19250  }
19251  for (const node of nodes) {
19252    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
19253    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
19254    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
19255    circle.setAttribute("fill", color(node.kind));
19256    circle.setAttribute("class", "node");
19257    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
19258    svg.appendChild(circle);
19259    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
19260    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
19261    label.setAttribute("class", "node-label");
19262    label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
19263    svg.appendChild(label);
19264  }
19265}
19266packets.innerHTML = report.worker_prompt_packets.map(packet => `<div class="row"><div class="kind">${escapeHtml(packet.contract_version)} - ${escapeHtml(packet.risk)} - parallel_safe ${packet.parallel_safe ? "true" : "false"} - closure ${packet.worker_feedback ? packet.worker_feedback.closure_rank_score : 0}</div><div class="label">${escapeHtml(packet.title)}</div><div class="handle">${escapeHtml(packet.packet_id)}</div><div class="handle">blocks ${escapeHtml((packet.blocks||[]).join(", ") || "none")} | blocked_by ${escapeHtml((packet.blocked_by||[]).join(", ") || "none")}</div></div>`).join("") || "<div class=\"meta\">No packets.</div>";
19267feedback.innerHTML = report.worker_feedback.map(item => `<div class="row"><div class="kind">completed ${item.completed} - blocked ${item.blocked} - closure ${item.closure_rank_score}</div><div>files ${escapeHtml((item.touched_files||[]).join(", ") || "none")}</div><div>tests ${escapeHtml((item.expected_tests||[]).join(" && ") || "none")}</div>${item.repeated_blockage ? "<div class=\"label\">Repeated blockage</div>" : ""}${(item.stale_expected_tests||[]).length ? `<div class="label">Stale tests: ${escapeHtml(item.stale_expected_tests.join(", "))}</div>` : ""}${(item.follow_up_debt||[]).length ? `<div class="label">Follow-up debt: ${escapeHtml(item.follow_up_debt.join(", "))}</div>` : ""}</div>`).join("") || "<div class=\"meta\">No worker results.</div>";
19268nodeList.innerHTML = nodes.map(node => `<div class="row"><div class="kind">${escapeHtml(node.kind)}</div><div class="label">${escapeHtml(node.label)}</div><div class="handle">${escapeHtml(node.id)}</div></div>`).join("");
19269window.addEventListener("resize", () => { layout(); draw(); });
19270layout(); draw();
19271</script></div></body></html>"##,
19272    );
19273    Ok(html)
19274}
19275
19276struct DispatchTraceOptions<'a> {
19277    path: &'a Path,
19278    scope: Option<&'a str>,
19279    raw_targets: &'a [String],
19280    depth: usize,
19281    limit: usize,
19282    impact_limit: usize,
19283    trace_format: DispatchTraceFormat,
19284}
19285
19286fn cmd_dispatch_trace(
19287    options: DispatchTraceOptions<'_>,
19288    output_format: OutputFormat,
19289) -> Result<()> {
19290    let report = build_dispatch_trace_report(
19291        options.path,
19292        options.scope,
19293        options.raw_targets,
19294        options.depth,
19295        options.limit,
19296        options.impact_limit,
19297    )?;
19298    match options.trace_format {
19299        DispatchTraceFormat::Json => {
19300            if output_format.envelope {
19301                print_json_or_envelope(
19302                    &report,
19303                    &output_format,
19304                    "dispatch-trace",
19305                    "operator-review",
19306                    ToolEnvelopeSummary {
19307                        text: format!(
19308                            "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
19309                            report.targets.len(),
19310                            report.nodes.len(),
19311                            report.worker_prompt_packets.len()
19312                        ),
19313                        metrics: vec![
19314                            envelope_metric("targets", report.targets.len()),
19315                            envelope_metric("nodes", report.nodes.len()),
19316                            envelope_metric("edges", report.edges.len()),
19317                            envelope_metric(
19318                                "worker_prompt_packets",
19319                                report.worker_prompt_packets.len(),
19320                            ),
19321                        ],
19322                    },
19323                    report.truncated,
19324                    report.replay_commands.clone(),
19325                )
19326            } else {
19327                println!(
19328                    "{}",
19329                    to_json_schema(
19330                        &report,
19331                        output_format.pretty,
19332                        output_format.terse,
19333                        output_format.ultra_terse,
19334                        output_format.schema
19335                    )?
19336                );
19337                Ok(())
19338            }
19339        }
19340        DispatchTraceFormat::Html => {
19341            println!("{}", dispatch_trace_html(&report)?);
19342            Ok(())
19343        }
19344    }
19345}
19346
19347#[derive(Clone, Debug)]
19348struct DependencyDagProfile {
19349    id: String,
19350    graph_node_id: String,
19351    label: String,
19352    path: Option<String>,
19353    line: Option<i64>,
19354    detail: Option<String>,
19355    source_files: BTreeSet<String>,
19356    source_symbols: BTreeSet<String>,
19357    config_files: BTreeSet<String>,
19358    expected_tests: BTreeSet<String>,
19359    semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
19360    worker_feedback: ConflictMatrixWorkerFeedback,
19361}
19362
19363#[derive(Clone, Debug, Serialize)]
19364struct DependencyDagNode {
19365    id: String,
19366    graph_node_id: String,
19367    label: String,
19368    #[serde(skip_serializing_if = "Option::is_none")]
19369    path: Option<String>,
19370    #[serde(skip_serializing_if = "Option::is_none")]
19371    line: Option<i64>,
19372    #[serde(skip_serializing_if = "Option::is_none")]
19373    detail: Option<String>,
19374    source_files: Vec<String>,
19375    source_symbols: Vec<String>,
19376    config_files: Vec<String>,
19377    expected_tests: Vec<String>,
19378    semantic_refs: Vec<ConflictMatrixSemanticRef>,
19379    worker_feedback: ConflictMatrixWorkerFeedback,
19380}
19381
19382#[derive(Clone, Debug, Serialize)]
19383struct DependencyDagEdge {
19384    from: String,
19385    to: String,
19386    kind: String,
19387    weight: usize,
19388    reasons: Vec<String>,
19389    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19390    shared_files: Vec<String>,
19391    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19392    shared_symbols: Vec<String>,
19393    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19394    shared_tests: Vec<String>,
19395    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19396    shared_config_files: Vec<String>,
19397    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19398    shared_semantic_refs: Vec<String>,
19399}
19400
19401#[derive(Clone, Debug, Serialize)]
19402struct DependencyDagTopoBatch {
19403    batch: usize,
19404    targets: Vec<String>,
19405}
19406
19407#[derive(Clone, Debug, Serialize)]
19408struct DependencyDagCycleDiagnostics {
19409    has_cycles: bool,
19410    blocked_nodes: Vec<String>,
19411    cycle_edges: Vec<DependencyDagEdge>,
19412}
19413
19414#[derive(Serialize)]
19415struct DependencyDagSummary {
19416    nodes: usize,
19417    edges: usize,
19418    topo_batches: usize,
19419    has_cycles: bool,
19420}
19421
19422#[derive(Serialize)]
19423struct DependencyDagReport {
19424    contract_version: &'static str,
19425    root: String,
19426    #[serde(skip_serializing_if = "Option::is_none")]
19427    scope: Option<String>,
19428    path: String,
19429    targets: Vec<String>,
19430    projection_freshness: GraphDbFreshnessReport,
19431    projection_hashes: Vec<String>,
19432    nodes: Vec<DependencyDagNode>,
19433    edges: Vec<DependencyDagEdge>,
19434    topo_batches: Vec<DependencyDagTopoBatch>,
19435    cycle_diagnostics: DependencyDagCycleDiagnostics,
19436    summary: DependencyDagSummary,
19437    replay_commands: Vec<String>,
19438    repair_commands: Vec<String>,
19439    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19440    warnings: Vec<String>,
19441}
19442
19443fn dependency_dag_backlog_node_for_target(
19444    store: &impl GraphStore,
19445    target: &str,
19446) -> Result<SubstrateGraphNode> {
19447    let resolved = graph_db_resolve_evidence_target(store, target)?
19448        .with_context(|| format!("dependency-dag target not found: {target}"))?;
19449    if resolved.kind == "backlog" {
19450        return Ok(resolved);
19451    }
19452    let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
19453        bail!(
19454            "dependency-dag target {} resolved to {} without a backlog ref_id",
19455            target,
19456            resolved.kind
19457        );
19458    };
19459    store
19460        .nodes_by_kind("backlog")?
19461        .into_iter()
19462        .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
19463        .min_by(|left, right| {
19464            left.properties
19465                .get("line")
19466                .and_then(|value| value.parse::<i64>().ok())
19467                .cmp(
19468                    &right
19469                        .properties
19470                        .get("line")
19471                        .and_then(|value| value.parse::<i64>().ok()),
19472                )
19473                .then(left.id.cmp(&right.id))
19474        })
19475        .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
19476}
19477
19478fn dependency_dag_resolve_backlog_nodes(
19479    root: &Path,
19480    path: &Path,
19481    store: &impl GraphStore,
19482    raw_targets: &[String],
19483) -> Result<Vec<SubstrateGraphNode>> {
19484    let mut nodes = Vec::new();
19485    let mut seen = BTreeSet::new();
19486    if raw_targets.is_empty() {
19487        let hinted_path = if path.is_absolute() {
19488            path.to_path_buf()
19489        } else {
19490            root.join(path)
19491        };
19492        let hinted_markdown = hinted_path
19493            .extension()
19494            .and_then(|ext| ext.to_str())
19495            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
19496        let hinted_rel = hinted_markdown.then(|| {
19497            relativize_pathbuf(&hinted_path, root)
19498                .to_string_lossy()
19499                .replace('\\', "/")
19500        });
19501        for node in store.nodes_by_kind("backlog")? {
19502            if let Some(expected_path) = &hinted_rel
19503                && node.properties.get("path") != Some(expected_path)
19504            {
19505                continue;
19506            }
19507            if seen.insert(node.id.clone()) {
19508                nodes.push(node);
19509            }
19510        }
19511        if nodes.is_empty() && hinted_rel.is_some() {
19512            for node in store.nodes_by_kind("backlog")? {
19513                if seen.insert(node.id.clone()) {
19514                    nodes.push(node);
19515                }
19516            }
19517        }
19518    } else {
19519        for target in raw_targets {
19520            let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19521            let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19522            if seen.insert(node.id.clone()) {
19523                nodes.push(node);
19524            }
19525        }
19526    }
19527    if nodes.is_empty() {
19528        bail!("dependency-dag needs at least one resolvable backlog id");
19529    }
19530    nodes.sort_by(|left, right| {
19531        left.properties
19532            .get("line")
19533            .and_then(|value| value.parse::<i64>().ok())
19534            .cmp(
19535                &right
19536                    .properties
19537                    .get("line")
19538                    .and_then(|value| value.parse::<i64>().ok()),
19539            )
19540            .then(left.id.cmp(&right.id))
19541    });
19542    Ok(nodes)
19543}
19544
19545fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19546    node.properties
19547        .get("ref_id")
19548        .cloned()
19549        .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19550}
19551
19552fn dependency_dag_node_profile(
19553    root: &Path,
19554    store: &impl GraphStore,
19555    node: &SubstrateGraphNode,
19556    graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19557    graph_edges: &[SubstrateGraphEdge],
19558    depth: usize,
19559    limit: usize,
19560) -> Result<DependencyDagProfile> {
19561    let id = dependency_dag_node_id(node);
19562    let mut source_files = BTreeSet::new();
19563    let mut source_symbols = BTreeSet::new();
19564    for edge in graph_edges
19565        .iter()
19566        .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19567    {
19568        let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19569            continue;
19570        };
19571        match target.kind.as_str() {
19572            "file" | "route" => {
19573                if let Some(path) = target.properties.get("path") {
19574                    source_files.insert(path.clone());
19575                }
19576            }
19577            "symbol" => {
19578                source_symbols.insert(target.label.clone());
19579                if let Some(path) = target.properties.get("path") {
19580                    source_files.insert(path.clone());
19581                }
19582            }
19583            _ => {}
19584        }
19585    }
19586
19587    let max_rows = if limit == 0 { usize::MAX } else { limit };
19588    for (source, _) in
19589        graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19590    {
19591        let terse: SubstrateTerseGraphNode = (&source).into();
19592        if let Some(handle) = conflict_matrix_source_handle(&terse) {
19593            source_files.insert(handle.file);
19594        }
19595    }
19596
19597    let worker_results = graph_nodes_by_id
19598        .values()
19599        .filter(|candidate| {
19600            candidate.kind == "worker_result"
19601                && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19602        })
19603        .map(SubstrateTerseGraphNode::from)
19604        .collect::<Vec<_>>();
19605    let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19606    let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19607    let config_files = source_files
19608        .iter()
19609        .filter(|file| is_planner_config_path(file))
19610        .cloned()
19611        .collect();
19612
19613    let mut semantic_refs = BTreeMap::new();
19614    for kind in ["semantic_concept", "semantic_entity"] {
19615        for (semantic, _) in
19616            graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19617        {
19618            let terse: SubstrateTerseGraphNode = (&semantic).into();
19619            let item = conflict_matrix_semantic_ref(root, &terse);
19620            semantic_refs
19621                .entry(format!("{}:{}", item.kind, item.label))
19622                .or_insert(item);
19623        }
19624    }
19625
19626    Ok(DependencyDagProfile {
19627        id,
19628        graph_node_id: node.id.clone(),
19629        label: node.label.clone(),
19630        path: node.properties.get("path").cloned(),
19631        line: node
19632            .properties
19633            .get("line")
19634            .and_then(|value| value.parse::<i64>().ok()),
19635        detail: node.properties.get("detail").cloned(),
19636        source_files,
19637        source_symbols,
19638        config_files,
19639        expected_tests,
19640        semantic_refs,
19641        worker_feedback,
19642    })
19643}
19644
19645fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19646    let lower = text.to_ascii_lowercase();
19647    let mut refs = Vec::new();
19648    for marker in markers {
19649        let mut offset = 0usize;
19650        while let Some(pos) = lower[offset..].find(marker) {
19651            let start = offset + pos + marker.len();
19652            let segment = text[start..]
19653                .split(['\n', '.'])
19654                .next()
19655                .unwrap_or(&text[start..]);
19656            refs.extend(extract_conflict_target_refs(segment));
19657            offset = start;
19658        }
19659    }
19660    dedupe_preserve_order(refs)
19661}
19662
19663fn dependency_dag_push_edge(
19664    edges: &mut Vec<DependencyDagEdge>,
19665    seen: &mut BTreeSet<(String, String, String)>,
19666    edge: DependencyDagEdge,
19667) {
19668    if edge.from == edge.to {
19669        return;
19670    }
19671    if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19672        edges.push(edge);
19673    }
19674}
19675
19676fn dependency_dag_explicit_edges(
19677    profiles: &[DependencyDagProfile],
19678    target_ids: &BTreeSet<String>,
19679    edges: &mut Vec<DependencyDagEdge>,
19680    seen: &mut BTreeSet<(String, String, String)>,
19681) {
19682    for profile in profiles {
19683        let detail = profile.detail.as_deref().unwrap_or_default();
19684        for dep in dependency_dag_marker_refs(
19685            detail,
19686            &[
19687                "depends on",
19688                "depends-on",
19689                "deps:",
19690                "after",
19691                "blocked by",
19692                "requires",
19693            ],
19694        ) {
19695            if target_ids.contains(&dep) {
19696                dependency_dag_push_edge(
19697                    edges,
19698                    seen,
19699                    DependencyDagEdge {
19700                        from: dep.clone(),
19701                        to: profile.id.clone(),
19702                        kind: "explicit_depends_on".to_string(),
19703                        weight: 1000,
19704                        reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19705                        shared_files: Vec::new(),
19706                        shared_symbols: Vec::new(),
19707                        shared_tests: Vec::new(),
19708                        shared_config_files: Vec::new(),
19709                        shared_semantic_refs: Vec::new(),
19710                    },
19711                );
19712            }
19713        }
19714        for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19715            if target_ids.contains(&downstream) {
19716                dependency_dag_push_edge(
19717                    edges,
19718                    seen,
19719                    DependencyDagEdge {
19720                        from: profile.id.clone(),
19721                        to: downstream.clone(),
19722                        kind: "explicit_before".to_string(),
19723                        weight: 900,
19724                        reasons: vec![format!(
19725                            "{} declares it should run before #{downstream}",
19726                            profile.id
19727                        )],
19728                        shared_files: Vec::new(),
19729                        shared_symbols: Vec::new(),
19730                        shared_tests: Vec::new(),
19731                        shared_config_files: Vec::new(),
19732                        shared_semantic_refs: Vec::new(),
19733                    },
19734                );
19735            }
19736        }
19737    }
19738}
19739
19740fn dependency_dag_worker_follow_up_edges(
19741    profiles: &[DependencyDagProfile],
19742    target_ids: &BTreeSet<String>,
19743    edges: &mut Vec<DependencyDagEdge>,
19744    seen: &mut BTreeSet<(String, String, String)>,
19745) {
19746    for profile in profiles {
19747        for follow_up in &profile.worker_feedback.follow_up_ids {
19748            if target_ids.contains(follow_up) {
19749                dependency_dag_push_edge(
19750                    edges,
19751                    seen,
19752                    DependencyDagEdge {
19753                        from: profile.id.clone(),
19754                        to: follow_up.clone(),
19755                        kind: "worker_result_follow_up".to_string(),
19756                        weight: 700,
19757                        reasons: vec![format!(
19758                            "worker_result for #{} references follow-up #{}",
19759                            profile.id, follow_up
19760                        )],
19761                        shared_files: Vec::new(),
19762                        shared_symbols: Vec::new(),
19763                        shared_tests: Vec::new(),
19764                        shared_config_files: Vec::new(),
19765                        shared_semantic_refs: Vec::new(),
19766                    },
19767                );
19768            }
19769        }
19770    }
19771}
19772
19773fn dependency_dag_overlap_edges(
19774    profiles: &[DependencyDagProfile],
19775    edges: &mut Vec<DependencyDagEdge>,
19776    seen: &mut BTreeSet<(String, String, String)>,
19777) {
19778    for left_idx in 0..profiles.len() {
19779        for right_idx in (left_idx + 1)..profiles.len() {
19780            let left = &profiles[left_idx];
19781            let right = &profiles[right_idx];
19782            let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19783            let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19784            let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19785            let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19786            let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19787            let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19788            let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19789            if shared_files.is_empty()
19790                && shared_symbols.is_empty()
19791                && shared_tests.is_empty()
19792                && shared_config_files.is_empty()
19793                && shared_semantic_refs.is_empty()
19794            {
19795                continue;
19796            }
19797            let kind = if shared_files.is_empty()
19798                && shared_symbols.is_empty()
19799                && shared_tests.is_empty()
19800                && shared_config_files.is_empty()
19801            {
19802                "semantic_relation"
19803            } else {
19804                "shared_resource"
19805            };
19806            let mut reasons = Vec::new();
19807            if !shared_files.is_empty() {
19808                reasons.push(format!("shared files: {}", shared_files.join(", ")));
19809            }
19810            if !shared_symbols.is_empty() {
19811                reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19812            }
19813            if !shared_tests.is_empty() {
19814                reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19815            }
19816            if !shared_config_files.is_empty() {
19817                reasons.push(format!(
19818                    "shared config files: {}",
19819                    shared_config_files.join(", ")
19820                ));
19821            }
19822            if !shared_semantic_refs.is_empty() {
19823                reasons.push(format!(
19824                    "shared semantic refs: {}",
19825                    shared_semantic_refs.join(", ")
19826                ));
19827            }
19828            let weight = shared_files.len() * 100
19829                + shared_config_files.len() * 100
19830                + shared_symbols.len() * 40
19831                + shared_tests.len() * 10
19832                + shared_semantic_refs.len() * 5;
19833            dependency_dag_push_edge(
19834                edges,
19835                seen,
19836                DependencyDagEdge {
19837                    from: left.id.clone(),
19838                    to: right.id.clone(),
19839                    kind: kind.to_string(),
19840                    weight,
19841                    reasons,
19842                    shared_files,
19843                    shared_symbols,
19844                    shared_tests,
19845                    shared_config_files,
19846                    shared_semantic_refs,
19847                },
19848            );
19849        }
19850    }
19851}
19852
19853fn dependency_dag_topo_batches(
19854    targets: &[String],
19855    edges: &[DependencyDagEdge],
19856) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19857    let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19858    let order = targets
19859        .iter()
19860        .enumerate()
19861        .map(|(idx, id)| (id.clone(), idx))
19862        .collect::<BTreeMap<_, _>>();
19863    let mut indegree = targets
19864        .iter()
19865        .map(|id| (id.clone(), 0usize))
19866        .collect::<BTreeMap<_, _>>();
19867    let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19868    let mut seen_pairs = BTreeSet::<(String, String)>::new();
19869    for edge in edges {
19870        if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19871            continue;
19872        }
19873        if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19874            continue;
19875        }
19876        *indegree.entry(edge.to.clone()).or_default() += 1;
19877        outgoing
19878            .entry(edge.from.clone())
19879            .or_default()
19880            .push(edge.to.clone());
19881    }
19882    for values in outgoing.values_mut() {
19883        values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19884        values.dedup();
19885    }
19886
19887    let mut processed = BTreeSet::new();
19888    let mut batches = Vec::new();
19889    loop {
19890        let mut ready = targets
19891            .iter()
19892            .filter(|id| !processed.contains(*id))
19893            .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19894            .cloned()
19895            .collect::<Vec<_>>();
19896        ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19897        if ready.is_empty() {
19898            break;
19899        }
19900        for id in &ready {
19901            processed.insert(id.clone());
19902            for next in outgoing.get(id).into_iter().flatten() {
19903                if let Some(value) = indegree.get_mut(next) {
19904                    *value = value.saturating_sub(1);
19905                }
19906            }
19907        }
19908        batches.push(DependencyDagTopoBatch {
19909            batch: batches.len() + 1,
19910            targets: ready,
19911        });
19912    }
19913
19914    let blocked_nodes = targets
19915        .iter()
19916        .filter(|id| !processed.contains(*id))
19917        .cloned()
19918        .collect::<Vec<_>>();
19919    let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19920    let cycle_edges = edges
19921        .iter()
19922        .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19923        .cloned()
19924        .collect::<Vec<_>>();
19925    (
19926        batches,
19927        DependencyDagCycleDiagnostics {
19928            has_cycles: !blocked_nodes.is_empty(),
19929            blocked_nodes,
19930            cycle_edges,
19931        },
19932    )
19933}
19934
19935fn dependency_dag_replay_commands(
19936    path: &Path,
19937    scope: Option<&str>,
19938    targets: &[String],
19939    depth: usize,
19940    limit: usize,
19941) -> Vec<String> {
19942    let target_args = targets
19943        .iter()
19944        .map(|target| shell_quote(target))
19945        .collect::<Vec<_>>()
19946        .join(" ");
19947    let mut command = format!(
19948        "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19949        shell_quote(path.to_string_lossy().as_ref()),
19950        scope
19951            .map(|scope| format!(" --scope {}", shell_quote(scope)))
19952            .unwrap_or_default(),
19953        depth,
19954        limit
19955    );
19956    if !target_args.is_empty() {
19957        command.push(' ');
19958        command.push_str(&target_args);
19959    }
19960    vec![command]
19961}
19962
19963fn build_dependency_dag_report(
19964    path: &Path,
19965    scope: Option<&str>,
19966    raw_targets: &[String],
19967    depth: usize,
19968    limit: usize,
19969) -> Result<DependencyDagReport> {
19970    let root = lint::resolve_project_root_or_canonical_path(path)?;
19971    write_traversal_graph_store(&root, path, scope)
19972        .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19973    let graph_db = graph_substrate_db_path(&root, scope);
19974    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19975        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19976    let mut warnings = Vec::new();
19977    if let Some(recovery) = store.read_only_recovery() {
19978        warnings.push(graph_db_read_recovery_diagnostic(recovery));
19979    }
19980    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19981    if freshness.fail_closed {
19982        bail!(
19983            "dependency-dag graph projection failed closed: {}; repair: {}",
19984            freshness.diagnostics.join("; "),
19985            graph_db_repair_commands(&root, scope).join("; ")
19986        );
19987    }
19988
19989    let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19990    let graph_nodes = store.all_nodes()?;
19991    let graph_edges = store.all_edges()?;
19992    let graph_nodes_by_id = graph_nodes
19993        .into_iter()
19994        .map(|node| (node.id.clone(), node))
19995        .collect::<BTreeMap<_, _>>();
19996    let profiles = target_nodes
19997        .iter()
19998        .map(|node| {
19999            dependency_dag_node_profile(
20000                &root,
20001                &store,
20002                node,
20003                &graph_nodes_by_id,
20004                &graph_edges,
20005                depth,
20006                limit,
20007            )
20008        })
20009        .collect::<Result<Vec<_>>>()?;
20010    let targets = profiles
20011        .iter()
20012        .map(|profile| profile.id.clone())
20013        .collect::<Vec<_>>();
20014    let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
20015
20016    let mut edges = Vec::new();
20017    let mut seen_edges = BTreeSet::new();
20018    dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
20019    dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
20020    dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
20021    edges.sort_by(|left, right| {
20022        left.from
20023            .cmp(&right.from)
20024            .then(left.to.cmp(&right.to))
20025            .then(left.kind.cmp(&right.kind))
20026    });
20027    let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
20028
20029    let nodes = profiles
20030        .into_iter()
20031        .map(|profile| DependencyDagNode {
20032            id: profile.id,
20033            graph_node_id: profile.graph_node_id,
20034            label: profile.label,
20035            path: profile.path,
20036            line: profile.line,
20037            detail: profile.detail,
20038            source_files: sorted_set(&profile.source_files),
20039            source_symbols: sorted_set(&profile.source_symbols),
20040            config_files: sorted_set(&profile.config_files),
20041            expected_tests: sorted_set(&profile.expected_tests),
20042            semantic_refs: profile.semantic_refs.into_values().collect(),
20043            worker_feedback: profile.worker_feedback,
20044        })
20045        .collect::<Vec<_>>();
20046    let projection_hashes = freshness
20047        .content_hash
20048        .clone()
20049        .into_iter()
20050        .collect::<Vec<_>>();
20051    let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
20052    let repair_commands = graph_db_repair_commands(&root, scope);
20053    let summary = DependencyDagSummary {
20054        nodes: nodes.len(),
20055        edges: edges.len(),
20056        topo_batches: topo_batches.len(),
20057        has_cycles: cycle_diagnostics.has_cycles,
20058    };
20059
20060    Ok(DependencyDagReport {
20061        contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
20062        root: root.to_string_lossy().to_string(),
20063        scope: scope.map(str::to_string),
20064        path: path.to_string_lossy().to_string(),
20065        targets,
20066        projection_freshness: freshness,
20067        projection_hashes,
20068        nodes,
20069        edges,
20070        topo_batches,
20071        cycle_diagnostics,
20072        summary,
20073        replay_commands,
20074        repair_commands,
20075        warnings,
20076    })
20077}
20078
20079fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
20080    if compact {
20081        println!(
20082            "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
20083            report.targets.len(),
20084            report.edges.len(),
20085            report.topo_batches.len(),
20086            report.cycle_diagnostics.has_cycles
20087        );
20088    } else {
20089        println!("Dependency DAG");
20090        println!("  targets: {}", report.targets.join(", "));
20091        println!("  edges:   {}", report.edges.len());
20092        println!("  cycles:  {}", report.cycle_diagnostics.has_cycles);
20093    }
20094    for batch in &report.topo_batches {
20095        println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
20096    }
20097    for edge in &report.edges {
20098        println!(
20099            "edge {} -> {} kind:{} weight:{}",
20100            edge.from, edge.to, edge.kind, edge.weight
20101        );
20102        for reason in &edge.reasons {
20103            println!("  reason: {reason}");
20104        }
20105    }
20106    if report.cycle_diagnostics.has_cycles {
20107        println!(
20108            "cycle blocked nodes: {}",
20109            report.cycle_diagnostics.blocked_nodes.join(", ")
20110        );
20111    }
20112    for command in &report.replay_commands {
20113        println!("replay: {command}");
20114    }
20115    for command in &report.repair_commands {
20116        println!("repair: {command}");
20117    }
20118    for warning in &report.warnings {
20119        println!("warning: {warning}");
20120    }
20121}
20122
20123fn cmd_dependency_dag(
20124    path: &Path,
20125    scope: Option<&str>,
20126    raw_targets: &[String],
20127    depth: usize,
20128    limit: usize,
20129    format: OutputFormat,
20130) -> Result<()> {
20131    let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
20132    if format.json_output {
20133        print_json_or_envelope(
20134            &report,
20135            &format,
20136            "dependency-dag",
20137            "topological-planning",
20138            ToolEnvelopeSummary {
20139                text: format!(
20140                    "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
20141                    report.targets.len(),
20142                    report.edges.len(),
20143                    report.topo_batches.len(),
20144                    report.cycle_diagnostics.has_cycles
20145                ),
20146                metrics: vec![
20147                    envelope_metric("targets", report.targets.len()),
20148                    envelope_metric("edges", report.edges.len()),
20149                    envelope_metric("topo_batches", report.topo_batches.len()),
20150                    envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
20151                ],
20152            },
20153            report.cycle_diagnostics.has_cycles,
20154            report.replay_commands.clone(),
20155        )
20156    } else {
20157        print_dependency_dag_human(&report, format.compact);
20158        Ok(())
20159    }
20160}
20161
20162/// Persist a bulky raw log behind an artifact handle and attach it to the
20163/// report, so the bounded digest references the full transcript via a stable
20164/// handle + expansion command instead of losing it (stdin) or relying on
20165/// inlined groups. No-op for small logs or when the artifacts dir is unwritable.
20166fn maybe_attach_log_digest_raw_artifact(
20167    root: &Path,
20168    report: &mut log_digest::LogDigestReport,
20169    input: &str,
20170) -> Result<()> {
20171    if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
20172        return Ok(());
20173    }
20174    let key = format!("logdigest:{}:{}", report.total_lines, input.len());
20175    let artifact_path = root
20176        .join(".tsift/artifacts")
20177        .join(format!("{}.log", stable_handle("logdg", &key)));
20178    let expand = format!(
20179        "tsift log-digest --path {} --input {} --json",
20180        shell_quote(root.to_string_lossy().as_ref()),
20181        shell_quote(artifact_path.to_string_lossy().as_ref())
20182    );
20183    let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
20184    report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
20185        handle: artifact.handle,
20186        path: artifact.path,
20187        bytes: artifact.bytes,
20188        lines: artifact.lines,
20189        expand: artifact.expand,
20190    });
20191    Ok(())
20192}
20193
20194/// Run the log-digest token-savings + false-negative fixture gate: prove the
20195/// digest both compresses raw cargo/pytest/npm/pnpm/agent-doc logs and preserves
20196/// their real signals. With `fail_under`, exits non-zero on any case miss.
20197pub(crate) fn render_log_digest_fixture(
20198    path: &Path,
20199    fixture_path: &Path,
20200    fail_under: bool,
20201    format: OutputFormat,
20202) -> Result<()> {
20203    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20204    let fixture_body = fs::read_to_string(fixture_path)
20205        .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
20206    let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
20207        .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
20208    let report = log_digest::evaluate_fixture(&root, &fixture)?;
20209
20210    if format.json_output {
20211        print_json_or_envelope(
20212            &report,
20213            &format,
20214            "log-digest-fixture",
20215            "report",
20216            ToolEnvelopeSummary {
20217                text: if report.passed {
20218                    format!("log-digest gate passed for {} case(s)", report.total_cases)
20219                } else {
20220                    format!("log-digest gate failed {} case(s)", report.failed_cases)
20221                },
20222                metrics: vec![
20223                    envelope_metric("cases", report.total_cases),
20224                    envelope_metric("failed", report.failed_cases),
20225                    envelope_metric("passed", report.passed),
20226                ],
20227            },
20228            false,
20229            vec![],
20230        )?;
20231    } else {
20232        println!("Log digest fixture gate");
20233        println!("  cases:  {}", report.total_cases);
20234        println!("  failed: {}", report.failed_cases);
20235        println!("  status: {}", if report.passed { "pass" } else { "fail" });
20236        for case in &report.cases {
20237            println!(
20238                "  [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
20239                if case.passed { "pass" } else { "FAIL" },
20240                case.name,
20241                case.ecosystem,
20242                case.savings_percent,
20243                case.minimum_savings_percent,
20244                case.raw_tokens,
20245                case.digest_tokens
20246            );
20247            if !case.missing_required_signals.is_empty() {
20248                println!(
20249                    "    missing required signals: {}",
20250                    case.missing_required_signals.join(", ")
20251                );
20252            }
20253            if !case.present_forbidden_signals.is_empty() {
20254                println!(
20255                    "    present forbidden signals: {}",
20256                    case.present_forbidden_signals.join(", ")
20257                );
20258            }
20259        }
20260    }
20261
20262    if fail_under && !report.passed {
20263        bail!("log-digest fixture gate failed");
20264    }
20265    Ok(())
20266}
20267
20268pub(crate) fn render_log_digest_from_input(
20269    path: &Path,
20270    input: &str,
20271    format: OutputFormat,
20272) -> Result<()> {
20273    let mut report = log_digest::compute(path, input)?;
20274    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20275    maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
20276    if format.json_output {
20277        println!(
20278            "{}",
20279            to_json_schema(
20280                &report,
20281                format.pretty,
20282                format.terse,
20283                format.ultra_terse,
20284                format.schema
20285            )?
20286        );
20287        return Ok(());
20288    }
20289
20290    if format.compact {
20291        println!(
20292            "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
20293            report.non_empty_lines,
20294            report.signal_groups,
20295            report.repeated_line_groups,
20296            report.file_ref_groups,
20297            report.symbol_ref_groups,
20298            report.stack_groups
20299        );
20300        for signal in &report.signals {
20301            let location = match (&signal.path, signal.line) {
20302                (Some(path), Some(line)) => format!("{path}:{line}"),
20303                (Some(path), None) => path.clone(),
20304                _ => "-".to_string(),
20305            };
20306            println!(
20307                "{} sev:{} count:{} sums:{} msg:{}",
20308                location,
20309                signal.severity,
20310                signal.occurrences,
20311                log_digest_summary_label(signal.summary_state),
20312                truncate_for_compact(&signal.message, 80)
20313            );
20314        }
20315        for repeated in &report.repeated_lines {
20316            println!(
20317                "repeat count:{} line:{}",
20318                repeated.occurrences,
20319                truncate_for_compact(&repeated.line, 80)
20320            );
20321        }
20322        for family in &report.line_families {
20323            println!(
20324                "family count:{} variants:{} template:{}",
20325                family.occurrences,
20326                family.variants,
20327                truncate_for_compact(&family.template, 80)
20328            );
20329        }
20330        for symbol in &report.symbol_refs {
20331            println!(
20332                "sym:{} count:{} sums:{}",
20333                symbol.symbol,
20334                symbol.occurrences,
20335                log_digest_summary_label(symbol.summary_state)
20336            );
20337        }
20338        if let Some(artifact) = &report.raw_log_artifact {
20339            println!(
20340                "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
20341                artifact.handle, artifact.lines, artifact.bytes, artifact.expand
20342            );
20343        }
20344        for warning in &report.warnings {
20345            println!("warning: {warning}");
20346        }
20347        return Ok(());
20348    }
20349
20350    println!("Log digest");
20351    println!("  lines:                    {}", report.total_lines);
20352    println!("  non-empty lines:          {}", report.non_empty_lines);
20353    println!("  signal groups:            {}", report.signal_groups);
20354    println!(
20355        "  repeated lines:           {}",
20356        report.repeated_line_groups
20357    );
20358    println!(
20359        "  repeated line instances:  {}",
20360        report.repeated_line_occurrences
20361    );
20362    println!("  line families:            {}", report.line_family_groups);
20363    println!("  file refs:                {}", report.file_ref_groups);
20364    println!("  symbol refs:              {}", report.symbol_ref_groups);
20365    println!("  stack groups:             {}", report.stack_groups);
20366
20367    if !report.signals.is_empty() {
20368        println!();
20369        println!("Signals:");
20370        for signal in &report.signals {
20371            match (&signal.path, signal.line, signal.column) {
20372                (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
20373                (Some(path), Some(line), None) => println!("{path}:{line}"),
20374                (Some(path), None, _) => println!("{path}"),
20375                (None, _, _) => println!("(no file anchor)"),
20376            }
20377            println!("  severity: {}", signal.severity);
20378            println!("  occurrences: {}", signal.occurrences);
20379            println!("  message: {}", signal.message);
20380            println!(
20381                "  cached summaries: {}",
20382                log_digest_summary_label(signal.summary_state)
20383            );
20384            for summary in &signal.current_summaries {
20385                println!(
20386                    "    - {}: {}",
20387                    summary.symbol,
20388                    truncate_for_compact(&summary.summary, 160)
20389                );
20390            }
20391        }
20392    }
20393
20394    if !report.repeated_lines.is_empty() {
20395        println!();
20396        println!("Repeated lines:");
20397        for repeated in &report.repeated_lines {
20398            println!(
20399                "  {}x {}",
20400                repeated.occurrences,
20401                truncate_for_compact(&repeated.line, 180)
20402            );
20403        }
20404    }
20405
20406    if !report.line_families.is_empty() {
20407        println!();
20408        println!("Line families (near-duplicate folds):");
20409        for family in &report.line_families {
20410            println!(
20411                "  {}x ({} variants) {}",
20412                family.occurrences,
20413                family.variants,
20414                truncate_for_compact(&family.template, 180)
20415            );
20416            println!(
20417                "    first: {}",
20418                truncate_for_compact(&family.first_sample, 180)
20419            );
20420            println!(
20421                "    last:  {}",
20422                truncate_for_compact(&family.last_sample, 180)
20423            );
20424        }
20425    }
20426
20427    if !report.file_refs.is_empty() {
20428        println!();
20429        println!("Anchored files:");
20430        for file_ref in &report.file_refs {
20431            match (file_ref.line, file_ref.column) {
20432                (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
20433                (Some(line), None) => println!("{}:{}", file_ref.path, line),
20434                (None, _) => println!("{}", file_ref.path),
20435            }
20436            println!("  occurrences: {}", file_ref.occurrences);
20437            println!(
20438                "  cached summaries: {}",
20439                log_digest_summary_label(file_ref.summary_state)
20440            );
20441            for summary in &file_ref.current_summaries {
20442                println!(
20443                    "    - {}: {}",
20444                    summary.symbol,
20445                    truncate_for_compact(&summary.summary, 160)
20446                );
20447            }
20448        }
20449    }
20450
20451    if !report.symbol_refs.is_empty() {
20452        println!();
20453        println!("Symbol candidates:");
20454        for symbol in &report.symbol_refs {
20455            println!("{}", symbol.symbol);
20456            println!("  occurrences: {}", symbol.occurrences);
20457            println!(
20458                "  cached summaries: {}",
20459                log_digest_summary_label(symbol.summary_state)
20460            );
20461            for summary in &symbol.current_summaries {
20462                println!(
20463                    "    - {}: {}",
20464                    summary.symbol,
20465                    truncate_for_compact(&summary.summary, 160)
20466                );
20467            }
20468        }
20469    }
20470
20471    if !report.stack_traces.is_empty() {
20472        println!();
20473        println!("Stack groups:");
20474        for stack in &report.stack_traces {
20475            println!("  occurrences: {}", stack.occurrences);
20476            for frame in &stack.frames {
20477                println!("    - {}", frame);
20478            }
20479        }
20480    }
20481
20482    if let Some(artifact) = &report.raw_log_artifact {
20483        println!();
20484        println!("Raw log artifact:");
20485        println!("  handle: {}", artifact.handle);
20486        println!("  path:   {}", artifact.path);
20487        println!("  lines:  {}", artifact.lines);
20488        println!("  bytes:  {}", artifact.bytes);
20489        println!("  expand: {}", artifact.expand);
20490    }
20491
20492    for warning in &report.warnings {
20493        println!("warning: {warning}");
20494    }
20495    Ok(())
20496}
20497
20498pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
20499    match trend {
20500        metric_digest::MetricDigestTrend::Improved => "improved",
20501        metric_digest::MetricDigestTrend::Regressed => "regressed",
20502        metric_digest::MetricDigestTrend::Flat => "flat",
20503        metric_digest::MetricDigestTrend::Unknown => "changed",
20504    }
20505}
20506
20507pub(crate) fn metric_digest_gate_label(
20508    decision: metric_digest::CommunitySearchGateDecision,
20509) -> &'static str {
20510    match decision {
20511        metric_digest::CommunitySearchGateDecision::Pass => "pass",
20512        metric_digest::CommunitySearchGateDecision::Block => "block",
20513    }
20514}
20515
20516pub(crate) fn memgraphrag_metric_digest_gate_label(
20517    decision: metric_digest::MemGraphRagPerformanceGateDecision,
20518) -> &'static str {
20519    match decision {
20520        metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
20521        metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
20522    }
20523}
20524
20525fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
20526    let input = fs::read_to_string(fixture_path)
20527        .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
20528    let report = dci_benchmark::compute(&input)?;
20529
20530    if format.json_output {
20531        println!(
20532            "{}",
20533            to_json_schema(
20534                &report,
20535                format.pretty,
20536                format.terse,
20537                format.ultra_terse,
20538                format.schema
20539            )?
20540        );
20541        return Ok(());
20542    }
20543
20544    if format.compact {
20545        println!(
20546            "dci tasks:{} strategies:{} warnings:{}",
20547            report.tasks_loaded,
20548            report.strategies_compared,
20549            report.warnings.len()
20550        );
20551        for summary in &report.strategy_summaries {
20552            println!(
20553                "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
20554                summary.strategy,
20555                summary.rank,
20556                summary.localized,
20557                summary.task_runs,
20558                dci_benchmark::format_number(summary.localization_rate * 100.0),
20559                dci_benchmark::format_number(summary.avg_useful_hits),
20560                dci_benchmark::format_number(summary.zero_output_rate * 100.0),
20561                dci_benchmark::format_number(summary.avg_tool_calls),
20562                dci_benchmark::format_number(summary.avg_latency_ms),
20563                dci_benchmark::format_number(summary.avg_estimated_tokens),
20564                dci_benchmark::format_number(summary.avg_output_tokens)
20565            );
20566        }
20567        if let Some(gate) = &report.memory_retrieval_gate {
20568            println!(
20569                "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
20570                gate.decision,
20571                gate.baseline_strategy,
20572                dci_benchmark::format_number(gate.min_avg_useful_hits),
20573                gate.max_zero_output_failures,
20574                gate.diagnostics.len()
20575            );
20576        }
20577        for warning in &report.warnings {
20578            println!("warning: {warning}");
20579        }
20580        return Ok(());
20581    }
20582
20583    println!("DCI benchmark");
20584    if let Some(description) = &report.description {
20585        println!("  description: {}", description);
20586    }
20587    println!("  tasks loaded:        {}", report.tasks_loaded);
20588    println!("  strategies compared: {}", report.strategies_compared);
20589
20590    println!();
20591    println!("Strategy summary:");
20592    for summary in &report.strategy_summaries {
20593        println!(
20594            "  #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
20595            summary.rank,
20596            summary.strategy,
20597            summary.localized,
20598            summary.task_runs,
20599            summary.localization_rate * 100.0,
20600            dci_benchmark::format_number(summary.avg_useful_hits),
20601            summary.zero_output_rate * 100.0,
20602            dci_benchmark::format_number(summary.avg_tool_calls),
20603            dci_benchmark::format_number(summary.avg_latency_ms),
20604            dci_benchmark::format_number(summary.avg_estimated_tokens),
20605            dci_benchmark::format_number(summary.avg_output_tokens)
20606        );
20607    }
20608
20609    if let Some(gate) = &report.memory_retrieval_gate {
20610        println!();
20611        println!("Memory retrieval gate:");
20612        println!("  decision: {}", gate.decision);
20613        println!(
20614            "  baseline: {}, min avg useful hits {}, max zero-output failures {}",
20615            gate.baseline_strategy,
20616            dci_benchmark::format_number(gate.min_avg_useful_hits),
20617            gate.max_zero_output_failures
20618        );
20619        for row in &gate.rows {
20620            println!(
20621                "  {}: status {}, avg useful hits {}, zero-output failures {}",
20622                row.strategy,
20623                row.status,
20624                dci_benchmark::format_number(row.avg_useful_hits),
20625                row.zero_output_failures
20626            );
20627        }
20628        for diagnostic in &gate.diagnostics {
20629            println!("  diagnostic: {diagnostic}");
20630        }
20631    }
20632
20633    println!();
20634    println!("Task winners:");
20635    for row in &report.task_rows {
20636        let label = row
20637            .label
20638            .as_ref()
20639            .map(|value| format!(" ({value})"))
20640            .unwrap_or_default();
20641        println!("  {}{}", row.task_id, label);
20642        println!("    localized: {}", row.best_localization.join(", "));
20643        println!("    most useful hits: {}", row.most_useful_hits.join(", "));
20644        println!(
20645            "    lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
20646            row.lowest_tool_calls.as_deref().unwrap_or("-"),
20647            row.lowest_latency.as_deref().unwrap_or("-"),
20648            row.lowest_token_budget.as_deref().unwrap_or("-"),
20649            row.lowest_output_tokens.as_deref().unwrap_or("-")
20650        );
20651        if !row.zero_output_failures.is_empty() {
20652            println!("    zero output: {}", row.zero_output_failures.join(", "));
20653        }
20654    }
20655
20656    for warning in &report.warnings {
20657        println!("warning: {warning}");
20658    }
20659    Ok(())
20660}
20661
20662pub(crate) fn format_compact_count(value: u64) -> String {
20663    if value >= 1_000_000 {
20664        format!("{:.1}M", value as f64 / 1_000_000.0)
20665    } else if value >= 1_000 {
20666        format!("{:.1}K", value as f64 / 1_000.0)
20667    } else {
20668        value.to_string()
20669    }
20670}
20671
20672fn cmd_digest_runner(
20673    kind: &str,
20674    path: &Path,
20675    runner: Option<&str>,
20676    shell_command: &str,
20677    format: OutputFormat,
20678) -> Result<()> {
20679    let digest_kind = DigestRunnerKind::parse(kind)?;
20680    let root = transcript_artifact_root(path)?;
20681    let execution = run_digest_runner_command(shell_command)?;
20682    let output = &execution.output;
20683    let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20684    let exit_code = output.status.code().unwrap_or(-1);
20685    if format.json_output && format.envelope {
20686        let artifact_key = format!(
20687            "{}:{}:{}:{}",
20688            digest_kind.as_str(),
20689            shell_command,
20690            execution.executed_command,
20691            captured
20692        );
20693        let artifact = if captured.trim().is_empty() {
20694            None
20695        } else {
20696            let (suffix, expand) = match digest_kind {
20697                DigestRunnerKind::Test => (
20698                    "test.log",
20699                    format!(
20700                        "tsift test-digest --path {} --input {}{} --json",
20701                        shell_quote(root.to_string_lossy().as_ref()),
20702                        shell_quote(
20703                            root.join(".tsift/artifacts")
20704                                .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20705                                .to_string_lossy()
20706                                .as_ref()
20707                        ),
20708                        runner
20709                            .map(|value| format!(" --runner {}", shell_quote(value)))
20710                            .unwrap_or_default()
20711                    ),
20712                ),
20713                DigestRunnerKind::Log => (
20714                    "log",
20715                    format!(
20716                        "tsift log-digest --path {} --input {} --json",
20717                        shell_quote(root.to_string_lossy().as_ref()),
20718                        shell_quote(
20719                            root.join(".tsift/artifacts")
20720                                .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20721                                .to_string_lossy()
20722                                .as_ref()
20723                        )
20724                    ),
20725                ),
20726            };
20727            Some(persist_transcript_artifact(
20728                &root,
20729                "tart",
20730                suffix,
20731                &artifact_key,
20732                &captured,
20733                expand,
20734            )?)
20735        };
20736        let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20737
20738        match digest_kind {
20739            DigestRunnerKind::Test => {
20740                let digest_report = test_digest::compute(path, &captured, runner)?;
20741                let report = serde_json::json!({
20742                    "kind": digest_kind.as_str(),
20743                    "command": shell_command,
20744                    "executed_command": execution.executed_command,
20745                    "exit_code": exit_code,
20746                    "success": output.status.success(),
20747                    "filter": filter_report,
20748                    "artifact": artifact,
20749                    "digest": digest_report,
20750                });
20751                let mut follow_up = artifact
20752                    .as_ref()
20753                    .map(|entry| vec![entry.expand.clone()])
20754                    .unwrap_or_default();
20755                follow_up.push(format!(
20756                    "tsift rewrite --run {}",
20757                    shell_quote(shell_command)
20758                ));
20759                let summary_text = if output.status.success() && digest_report.failures == 0 {
20760                    format!("test run passed for {}", runner.unwrap_or("auto"))
20761                } else {
20762                    format!("test run captured {} failure(s)", digest_report.failures)
20763                };
20764                print_json_or_envelope(
20765                    &report,
20766                    &format,
20767                    "digest-runner",
20768                    "test-run",
20769                    ToolEnvelopeSummary {
20770                        text: summary_text,
20771                        metrics: vec![
20772                            envelope_metric("runner", &digest_report.runner),
20773                            envelope_metric("exit_code", exit_code),
20774                            envelope_metric("filter", execution.filter_label()),
20775                            envelope_metric("failures", digest_report.failures),
20776                            envelope_metric("groups", digest_report.grouped_failures),
20777                            envelope_metric(
20778                                "artifact",
20779                                artifact
20780                                    .as_ref()
20781                                    .map(|entry| entry.handle.as_str())
20782                                    .unwrap_or("-"),
20783                            ),
20784                        ],
20785                    },
20786                    false,
20787                    follow_up,
20788                )?;
20789            }
20790            DigestRunnerKind::Log => {
20791                let digest_report = log_digest::compute(path, &captured)?;
20792                let report = serde_json::json!({
20793                    "kind": digest_kind.as_str(),
20794                    "command": shell_command,
20795                    "executed_command": execution.executed_command,
20796                    "exit_code": exit_code,
20797                    "success": output.status.success(),
20798                    "filter": filter_report,
20799                    "artifact": artifact,
20800                    "digest": digest_report,
20801                });
20802                let mut follow_up = artifact
20803                    .as_ref()
20804                    .map(|entry| vec![entry.expand.clone()])
20805                    .unwrap_or_default();
20806                follow_up.push(format!(
20807                    "tsift rewrite --run {}",
20808                    shell_quote(shell_command)
20809                ));
20810                let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20811                    "command finished without log signals".to_string()
20812                } else {
20813                    format!(
20814                        "command emitted {} log signal group(s)",
20815                        digest_report.signal_groups
20816                    )
20817                };
20818                print_json_or_envelope(
20819                    &report,
20820                    &format,
20821                    "digest-runner",
20822                    "command-run",
20823                    ToolEnvelopeSummary {
20824                        text: summary_text,
20825                        metrics: vec![
20826                            envelope_metric("exit_code", exit_code),
20827                            envelope_metric("filter", execution.filter_label()),
20828                            envelope_metric("signals", digest_report.signal_groups),
20829                            envelope_metric("file_refs", digest_report.file_ref_groups),
20830                            envelope_metric(
20831                                "artifact",
20832                                artifact
20833                                    .as_ref()
20834                                    .map(|entry| entry.handle.as_str())
20835                                    .unwrap_or("-"),
20836                            ),
20837                        ],
20838                    },
20839                    false,
20840                    follow_up,
20841                )?;
20842            }
20843        }
20844
20845        if output.status.success() {
20846            return Ok(());
20847        }
20848        if let Some(code) = output.status.code() {
20849            std::process::exit(code);
20850        }
20851        bail!("digest-wrapped command terminated by signal: {shell_command}");
20852    }
20853
20854    if captured.trim().is_empty() {
20855        let label = match digest_kind {
20856            DigestRunnerKind::Test => "test",
20857            DigestRunnerKind::Log => "log",
20858        };
20859        println!("No {label} output captured.");
20860    } else {
20861        match digest_kind {
20862            DigestRunnerKind::Test => {
20863                render_test_digest_from_input(path, &captured, runner, format)?
20864            }
20865            DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20866        }
20867    }
20868
20869    if output.status.success() {
20870        return Ok(());
20871    }
20872    if let Some(code) = output.status.code() {
20873        std::process::exit(code);
20874    }
20875    bail!("digest-wrapped command terminated by signal: {shell_command}");
20876}
20877
20878struct DigestRunnerExecution {
20879    output: std::process::Output,
20880    executed_command: String,
20881    filter: Option<DigestRunnerFilter>,
20882}
20883
20884impl DigestRunnerExecution {
20885    fn filter_label(&self) -> &'static str {
20886        self.filter
20887            .as_ref()
20888            .map(|filter| filter.tool)
20889            .unwrap_or("none")
20890    }
20891}
20892
20893struct DigestRunnerFilter {
20894    tool: &'static str,
20895    command: String,
20896}
20897
20898impl DigestRunnerFilter {
20899    fn to_json(&self) -> serde_json::Value {
20900        serde_json::json!({
20901            "tool": self.tool,
20902            "command": self.command,
20903        })
20904    }
20905}
20906
20907fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20908    let filter = rtk_rewrite_for_digest_runner(shell_command);
20909    let executed_command = filter
20910        .as_ref()
20911        .map(|filter| filter.command.as_str())
20912        .unwrap_or(shell_command);
20913    let output = Command::new("sh")
20914        .arg("-lc")
20915        .arg(format!("({executed_command}) 2>&1"))
20916        .stdout(Stdio::piped())
20917        .output()
20918        .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20919
20920    Ok(DigestRunnerExecution {
20921        output,
20922        executed_command: executed_command.to_string(),
20923        filter,
20924    })
20925}
20926
20927fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20928    if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20929        return None;
20930    }
20931    let output = Command::new("rtk")
20932        .arg("rewrite")
20933        .arg(shell_command)
20934        .output()
20935        .ok()?;
20936    if !output.status.success() {
20937        return None;
20938    }
20939    let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20940    if rewritten.is_empty() || rewritten == shell_command {
20941        return None;
20942    }
20943    Some(DigestRunnerFilter {
20944        tool: "rtk",
20945        command: rewritten,
20946    })
20947}
20948
20949fn find_command_on_path(command: &str) -> Option<PathBuf> {
20950    let path_var = std::env::var_os("PATH")?;
20951    std::env::split_paths(&path_var)
20952        .map(|dir| dir.join(command))
20953        .find(|candidate| candidate.is_file())
20954}
20955
20956pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20957    if !db_path.exists() {
20958        bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20959    }
20960    summarize::SummaryDb::open_read_only_resilient(db_path)
20961}
20962
20963fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20964    !matches!(report.index, status::IndexStatus::Fresh { .. })
20965}
20966
20967fn status_workspace_scope_ids_needing_fix(
20968    report: &status::StatusReport,
20969) -> std::collections::HashSet<&str> {
20970    let (workspace_scopes, missing_scopes) = match &report.index {
20971        status::IndexStatus::Fresh {
20972            workspace_scopes,
20973            missing_scopes,
20974            ..
20975        }
20976        | status::IndexStatus::Stale {
20977            workspace_scopes,
20978            missing_scopes,
20979            ..
20980        } => (workspace_scopes.as_slice(), missing_scopes.as_slice()),
20981        status::IndexStatus::Missing { missing_scopes } => (&[][..], missing_scopes.as_slice()),
20982    };
20983
20984    workspace_scopes
20985        .iter()
20986        .filter(|scope| scope.stale_files > 0)
20987        .map(|scope| scope.scope.as_str())
20988        .chain(missing_scopes.iter().map(|scope| scope.scope.as_str()))
20989        .collect()
20990}
20991
20992fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20993    !matches!(report.instructions, init::InstructionStatus::Current { .. })
20994}
20995
20996pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20997    if status_instructions_need_fix(report) {
20998        eprintln!("status fix: refreshing tsift instructions");
20999        init::init(root, false, false)?;
21000    }
21001
21002    let eviction = cycle_packet_cache::cycle_packet_cache_evict(
21003        root,
21004        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
21005        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
21006    );
21007    if eviction.evicted_entries > 0 {
21008        eprintln!(
21009            "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
21010            eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
21011        );
21012    }
21013
21014    if !status_index_needs_fix(report) {
21015        return Ok(());
21016    }
21017
21018    let scopes = config::Config::submodule_dirs(root)?;
21019    if scopes.is_empty() {
21020        eprintln!("status fix: refreshing index");
21021        run_index_update(
21022            &root.join(".tsift/index.db"),
21023            root,
21024            "status --fix refreshing index".to_string(),
21025            root,
21026            None,
21027            false,
21028            false,
21029        )?;
21030        return Ok(());
21031    }
21032
21033    let cfg = config::Config::load(root)?;
21034    let scope_ids_needing_fix = status_workspace_scope_ids_needing_fix(report);
21035    for scope in scopes {
21036        if !scope_ids_needing_fix.contains(scope.id.as_str()) {
21037            continue;
21038        }
21039        if !scope.source_root.exists() {
21040            eprintln!(
21041                "status fix: skipping missing submodule `{}` ({})",
21042                scope.id,
21043                scope.source_root.display()
21044            );
21045            continue;
21046        }
21047        eprintln!("status fix: refreshing submodule `{}` index", scope.id);
21048        run_index_update(
21049            &cfg.db_path_for(root, &scope.id),
21050            &scope.source_root,
21051            format!("status --fix refreshing submodule `{}` index", scope.id),
21052            root,
21053            Some(scope.id.as_str()),
21054            false,
21055            false,
21056        )?;
21057    }
21058
21059    Ok(())
21060}
21061
21062pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
21063    match &report.index {
21064        status::IndexStatus::Fresh { missing_scopes, .. }
21065        | status::IndexStatus::Stale { missing_scopes, .. }
21066        | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
21067    }
21068}
21069
21070pub(crate) fn autoindex_missing_workspace_scopes(
21071    root: &Path,
21072    report: &status::StatusReport,
21073) -> Result<()> {
21074    let missing_scopes = match &report.index {
21075        status::IndexStatus::Fresh { missing_scopes, .. }
21076        | status::IndexStatus::Stale { missing_scopes, .. }
21077        | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
21078    };
21079    if missing_scopes.is_empty() {
21080        return Ok(());
21081    }
21082
21083    let missing_scope_ids = missing_scopes
21084        .iter()
21085        .map(|scope| scope.scope.as_str())
21086        .collect::<std::collections::HashSet<_>>();
21087    let cfg = config::Config::load(root)?;
21088    for scope in config::Config::submodule_dirs(root)? {
21089        if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
21090            continue;
21091        }
21092        let db_path = cfg.db_path_for(root, &scope.id);
21093        run_index_update(
21094            &db_path,
21095            &scope.source_root,
21096            format!(
21097                "autoindexing missing submodule `{}` during status",
21098                scope.id
21099            ),
21100            root,
21101            Some(scope.id.as_str()),
21102            false,
21103            false,
21104        )?;
21105    }
21106    Ok(())
21107}
21108
21109pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
21110    for warning in &stats.warnings {
21111        let rel_path = relativize_pathbuf(&warning.path, root);
21112        eprintln!(
21113            "warning: summarize stats {}: {}",
21114            rel_path.display(),
21115            warning.message
21116        );
21117    }
21118}
21119
21120fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
21121    Result::<(), anyhow::Error>::Err(err)
21122        .context(context)
21123        .unwrap_err()
21124}
21125
21126fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
21127    let message = err.to_string();
21128    message.contains("another tsift index writer is already active")
21129        || substrate::error_mentions_locked_db(err)
21130}
21131
21132fn add_write_lock_context(
21133    err: anyhow::Error,
21134    action: String,
21135    root: &std::path::Path,
21136    scope: Option<&str>,
21137) -> anyhow::Error {
21138    if !should_attach_lock_diagnostics(&err) {
21139        return contextualize_error(err, action);
21140    }
21141
21142    let Ok(report) = status::check_locks(root, None, scope) else {
21143        return contextualize_error(err, action);
21144    };
21145
21146    contextualize_error(
21147        err,
21148        format!(
21149            "{}\n\nlock diagnostics:\n{}",
21150            action,
21151            status::format_locks_human(&report, false).trim_end()
21152        ),
21153    )
21154}
21155
21156pub(crate) fn run_index_update(
21157    db_path: &std::path::Path,
21158    source_root: &std::path::Path,
21159    action: String,
21160    root: &std::path::Path,
21161    scope: Option<&str>,
21162    rebuild: bool,
21163    prune: bool,
21164) -> Result<index::IndexSummary> {
21165    let result = (|| {
21166        let db = index::IndexDb::open(db_path)?;
21167        if rebuild {
21168            db.rebuild(source_root)
21169        } else if prune {
21170            db.apply_changes_pruned(source_root)
21171        } else {
21172            db.apply_changes(source_root)
21173        }
21174    })();
21175
21176    let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
21177    emit_index_warnings(&summary, source_root, scope);
21178    Ok(summary)
21179}
21180
21181pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
21182    for change in &mut summary.changes {
21183        change.path = relativize_pathbuf(&change.path, root);
21184    }
21185    for warning in &mut summary.warnings {
21186        warning.path = relativize_pathbuf(&warning.path, root);
21187    }
21188}
21189
21190fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
21191    for warning in &summary.warnings {
21192        let rel_path = relativize_pathbuf(&warning.path, root);
21193        let stage = match warning.stage {
21194            index::IndexWarningStage::ReadSource => "read failed",
21195            index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
21196            index::IndexWarningStage::ExtractCallSites => "call extraction failed",
21197            index::IndexWarningStage::ExtractRoutes => "route extraction failed",
21198        };
21199        let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
21200        let lang_suffix = warning
21201            .language
21202            .as_deref()
21203            .map(|lang| format!(" [{}]", lang))
21204            .unwrap_or_default();
21205        eprintln!(
21206            "warning: {}{}{}: {}: {}",
21207            scope_prefix,
21208            rel_path.display(),
21209            lang_suffix,
21210            stage,
21211            warning.message
21212        );
21213    }
21214}
21215
21216pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
21217    let config_path = root.join(".tsift/config.toml");
21218    if !config_path.exists() {
21219        return summarize::SummarizeConfig::default();
21220    }
21221    #[derive(serde::Deserialize, Default)]
21222    struct RawConfig {
21223        #[serde(default)]
21224        summarize: Option<RawSummarize>,
21225    }
21226    #[derive(serde::Deserialize)]
21227    struct RawSummarize {
21228        model: Option<String>,
21229        max_file_tokens: Option<usize>,
21230        api_key_env: Option<String>,
21231    }
21232    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
21233    let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
21234    let defaults = summarize::SummarizeConfig::default();
21235    match raw.summarize {
21236        Some(s) => summarize::SummarizeConfig {
21237            model: s.model.unwrap_or(defaults.model),
21238            max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
21239            api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
21240        },
21241        None => defaults,
21242    }
21243}
21244
21245#[derive(Debug, Clone, PartialEq, Eq)]
21246struct ExtractSymbolContext {
21247    db_path: PathBuf,
21248    source_root: PathBuf,
21249}
21250
21251pub(crate) fn find_symbols_db_for_file(
21252    root: &Path,
21253    file_path: &Path,
21254) -> Result<Option<ExtractSymbolContext>> {
21255    let cfg = config::Config::load(root)?;
21256    let mut submodules = config::Config::submodule_dirs(root)?;
21257    submodules.sort_by(|left, right| {
21258        right
21259            .source_root
21260            .components()
21261            .count()
21262            .cmp(&left.source_root.components().count())
21263    });
21264
21265    for scope in submodules {
21266        if !file_path.starts_with(&scope.source_root) {
21267            continue;
21268        }
21269        let db_path = cfg.db_path_for(root, &scope.id);
21270        if db_path.exists() {
21271            return Ok(Some(ExtractSymbolContext {
21272                db_path,
21273                source_root: scope.source_root,
21274            }));
21275        }
21276    }
21277
21278    let single = root.join(".tsift/index.db");
21279    if single.exists() && file_path.starts_with(root) {
21280        return Ok(Some(ExtractSymbolContext {
21281            db_path: single,
21282            source_root: root.to_path_buf(),
21283        }));
21284    }
21285
21286    Ok(None)
21287}
21288
21289pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
21290    let canonical = path
21291        .canonicalize()
21292        .with_context(|| format!("canonicalizing {}", path.display()))?;
21293
21294    Ok(if canonical.is_dir() {
21295        canonical
21296    } else {
21297        canonical
21298            .parent()
21299            .map(Path::to_path_buf)
21300            .unwrap_or(canonical)
21301    })
21302}
21303
21304fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
21305    if path.exists() {
21306        return path
21307            .canonicalize()
21308            .with_context(|| format!("canonicalizing extract scope {}", path.display()));
21309    }
21310
21311    Ok(summarize::normalize_lexical_path(path))
21312}
21313
21314pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
21315    let scope = if extract_path.is_absolute() {
21316        extract_path.to_path_buf()
21317    } else {
21318        root.join(extract_path)
21319    };
21320    normalize_extract_scope_path(&scope)
21321}
21322
21323pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
21324    normalize_extract_scope_path(changed_path)
21325        .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
21326        .starts_with(extract_scope)
21327}
21328
21329pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
21330    summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
21331}
21332
21333pub(crate) fn summarize_full_extract_deleted_summary_paths(
21334    summary_db: &summarize::SummaryDb,
21335    root: &Path,
21336    extract_scope: &Path,
21337    files_to_extract: &[PathBuf],
21338) -> Result<BTreeSet<String>> {
21339    let live_paths = files_to_extract
21340        .iter()
21341        .map(|file_path| summarize_relative_file_path(root, file_path))
21342        .collect::<BTreeSet<_>>();
21343    let mut deleted = BTreeSet::new();
21344
21345    for cached_path in summary_db.cached_file_paths()? {
21346        if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
21347            continue;
21348        }
21349        if !live_paths.contains(&cached_path) {
21350            deleted.insert(cached_path);
21351        }
21352    }
21353
21354    Ok(deleted)
21355}
21356
21357#[derive(Debug, Clone)]
21358struct SearchIndexTarget {
21359    label: String,
21360    db_path: PathBuf,
21361    source_root: PathBuf,
21362    scope_name: Option<String>,
21363    reindex_cmd: String,
21364}
21365
21366fn cargo_package_index_target(
21367    root: &Path,
21368    package: multiplicity::CargoPackageInfo,
21369) -> SearchIndexTarget {
21370    SearchIndexTarget {
21371        label: format!("cargo package `{}` index", package.scope_id),
21372        db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
21373        source_root: package.package_root.clone(),
21374        scope_name: Some(package.scope_id.clone()),
21375        reindex_cmd: format!(
21376            "tsift index --submodule {} {}",
21377            package.scope_id,
21378            root.display()
21379        ),
21380    }
21381}
21382
21383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21384enum SearchIndexState {
21385    Missing,
21386    Fresh,
21387    Stale { stale_files: usize },
21388}
21389
21390fn resolve_search_index_targets(
21391    root: &Path,
21392    path_hint: &Path,
21393    scope: Option<&str>,
21394    federated: bool,
21395) -> Result<Vec<SearchIndexTarget>> {
21396    if let Some(scope_name) = scope {
21397        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
21398            let cfg = config::Config::load(root)?;
21399            return Ok(vec![SearchIndexTarget {
21400                label: format!("submodule `{}` index", scope.id),
21401                db_path: cfg.db_path_for(root, &scope.id),
21402                source_root: scope.source_root.clone(),
21403                scope_name: Some(scope.id.clone()),
21404                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21405            }]);
21406        }
21407        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
21408            return Ok(vec![cargo_package_index_target(root, package)]);
21409        }
21410        config::Config::resolve_submodule(root, scope_name)?;
21411    }
21412
21413    if federated {
21414        let cfg = config::Config::load(root)?;
21415        let mut targets = Vec::new();
21416        for scope in config::Config::submodule_dirs(root)? {
21417            if !cfg.federation_for_scope(&scope) {
21418                continue;
21419            }
21420            targets.push(SearchIndexTarget {
21421                label: format!("submodule `{}` index", scope.id),
21422                db_path: cfg.db_path_for(root, &scope.id),
21423                source_root: scope.source_root.clone(),
21424                scope_name: Some(scope.id.clone()),
21425                reindex_cmd: format!("tsift index --workspace {}", root.display()),
21426            });
21427        }
21428        return Ok(targets);
21429    }
21430
21431    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
21432        let cfg = config::Config::load(root)?;
21433        return Ok(vec![SearchIndexTarget {
21434            label: format!("submodule `{}` index", scope.id),
21435            db_path: cfg.db_path_for(root, &scope.id),
21436            source_root: scope.source_root.clone(),
21437            scope_name: Some(scope.id.clone()),
21438            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21439        }]);
21440    }
21441
21442    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
21443        return Ok(vec![cargo_package_index_target(root, package)]);
21444    }
21445
21446    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
21447        let cfg = config::Config::load(root)?;
21448        return Ok(vec![SearchIndexTarget {
21449            label: format!("submodule `{}` index", scope.id),
21450            db_path: cfg.db_path_for(root, &scope.id),
21451            source_root: scope.source_root.clone(),
21452            scope_name: Some(scope.id.clone()),
21453            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21454        }]);
21455    }
21456
21457    let scopes = config::Config::submodule_dirs(root)?;
21458    if !scopes.is_empty() {
21459        let root_db = root.join(".tsift/index.db");
21460        if !root_db.exists() {
21461            let available_scopes = scopes
21462                .iter()
21463                .map(|scope| scope.id.as_str())
21464                .collect::<Vec<_>>()
21465                .join(", ");
21466            let cfg = config::Config::load(root)?;
21467            let indexed_scopes = scopes
21468                .iter()
21469                .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
21470                .map(|scope| scope.id.as_str())
21471                .collect::<Vec<_>>();
21472            let indexed_label = if indexed_scopes.is_empty() {
21473                "none".to_string()
21474            } else {
21475                indexed_scopes.join(", ")
21476            };
21477            bail!(
21478                "workspace root {} has no shared root index at {}. Default search requires `--scope <scope>` or `--federated` when the workspace uses scoped `.tsift/indexes/*/index.db` files. Available scopes: {}. Indexed scopes: {}.",
21479                root.display(),
21480                root_db.display(),
21481                available_scopes,
21482                indexed_label,
21483            );
21484        }
21485    }
21486
21487    Ok(vec![SearchIndexTarget {
21488        label: "index".to_string(),
21489        db_path: root.join(".tsift/index.db"),
21490        source_root: root.to_path_buf(),
21491        scope_name: None,
21492        reindex_cmd: format!("tsift index {}", root.display()),
21493    }])
21494}
21495
21496fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
21497    if !target.source_root.exists() || !target.db_path.exists() {
21498        return Ok(SearchIndexState::Missing);
21499    }
21500
21501    let inspection =
21502        index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
21503    let stale_files =
21504        inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
21505    if stale_files == 0 {
21506        Ok(SearchIndexState::Fresh)
21507    } else {
21508        Ok(SearchIndexState::Stale { stale_files })
21509    }
21510}
21511
21512#[derive(Debug, Clone, PartialEq, Eq)]
21513struct RebuildSearchTarget {
21514    label: String,
21515    reason: RebuildSearchReason,
21516    reindex_cmd: String,
21517}
21518
21519#[derive(Debug, Clone, PartialEq, Eq)]
21520enum RebuildSearchReason {
21521    Missing,
21522    Stale { stale_files: usize },
21523}
21524
21525#[derive(Debug, Clone, PartialEq, Eq)]
21526struct DegradedSearchTarget {
21527    label: String,
21528    reason: RebuildSearchReason,
21529    reindex_cmd: String,
21530}
21531
21532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21533pub(crate) enum DegradedSearchMode {
21534    ReadOnly,
21535    Exact,
21536}
21537
21538#[derive(Debug)]
21539struct SearchPrecheck {
21540    targets: Vec<SearchIndexTarget>,
21541    degraded_targets: Vec<DegradedSearchTarget>,
21542}
21543
21544fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
21545    err.chain().any(|cause| {
21546        cause
21547            .to_string()
21548            .contains("another tsift index writer is already active")
21549    })
21550}
21551
21552fn infer_agent_doc_task_submodule(
21553    root: &Path,
21554    path_hint: &Path,
21555) -> Result<Option<config::WorkspaceScope>> {
21556    let hinted_path = if path_hint.is_absolute() {
21557        path_hint.to_path_buf()
21558    } else {
21559        root.join(path_hint)
21560    };
21561    let Ok(relative) = hinted_path.strip_prefix(root) else {
21562        return Ok(None);
21563    };
21564    let mut components = relative.components();
21565    let Some(std::path::Component::Normal(first)) = components.next() else {
21566        return Ok(None);
21567    };
21568    if first != "tasks" {
21569        return Ok(None);
21570    }
21571    let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
21572        return Ok(None);
21573    };
21574    config::Config::find_submodule(root, file_stem)
21575}
21576
21577fn degraded_search_target(
21578    target: &SearchIndexTarget,
21579    reason: RebuildSearchReason,
21580) -> DegradedSearchTarget {
21581    DegradedSearchTarget {
21582        label: target.label.clone(),
21583        reason,
21584        reindex_cmd: target.reindex_cmd.clone(),
21585    }
21586}
21587
21588fn apply_search_index_update(
21589    root: &Path,
21590    target: &SearchIndexTarget,
21591) -> Result<index::IndexSummary> {
21592    run_index_update(
21593        &target.db_path,
21594        &target.source_root,
21595        format!("autoindexing {}", target.label),
21596        root,
21597        target.scope_name.as_deref(),
21598        false,
21599        false,
21600    )
21601}
21602
21603fn collect_rebuild_search_targets(
21604    targets: &[SearchIndexTarget],
21605) -> Result<Vec<RebuildSearchTarget>> {
21606    let mut rebuild_targets = Vec::new();
21607    for target in targets {
21608        let reason = match inspect_search_index(target)? {
21609            SearchIndexState::Missing => RebuildSearchReason::Missing,
21610            SearchIndexState::Fresh => continue,
21611            SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
21612        };
21613        rebuild_targets.push(RebuildSearchTarget {
21614            label: target.label.clone(),
21615            reason,
21616            reindex_cmd: target.reindex_cmd.clone(),
21617        });
21618    }
21619    Ok(rebuild_targets)
21620}
21621
21622fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
21623    match target.reason {
21624        RebuildSearchReason::Missing => format!("{} is missing", target.label),
21625        RebuildSearchReason::Stale { stale_files } => {
21626            let file_suffix = if stale_files == 1 { "" } else { "s" };
21627            format!(
21628                "{} is stale ({} file{})",
21629                target.label, stale_files, file_suffix
21630            )
21631        }
21632    }
21633}
21634
21635fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
21636    if rebuild_targets.len() == 1 {
21637        let target = &rebuild_targets[0];
21638        return format!(
21639            "{}. Run `{}` to rebuild before retrying.",
21640            rebuild_search_target_detail(target),
21641            target.reindex_cmd
21642        );
21643    }
21644
21645    let summary: Vec<String> = rebuild_targets
21646        .iter()
21647        .take(3)
21648        .map(rebuild_search_target_detail)
21649        .collect();
21650    let overflow = rebuild_targets.len().saturating_sub(summary.len());
21651    let mut details = summary.join(", ");
21652    if overflow > 0 {
21653        details.push_str(&format!(", +{} more", overflow));
21654    }
21655    let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
21656    format!(
21657        "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
21658        rebuild_targets.len(),
21659        details,
21660        reindex_cmd
21661    )
21662}
21663
21664pub(crate) fn precheck_search_indexes(
21665    root: &Path,
21666    path_hint: &Path,
21667    scope: Option<&str>,
21668    federated: bool,
21669    autoindex: bool,
21670) -> Result<SearchPrecheck> {
21671    let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
21672    let mut stale_targets = Vec::new();
21673    let mut degraded_targets = Vec::new();
21674
21675    for target in &targets {
21676        match inspect_search_index(target)? {
21677            SearchIndexState::Missing => {
21678                if autoindex && let Err(err) = apply_search_index_update(root, target) {
21679                    if is_active_writer_lock_error(&err) {
21680                        degraded_targets
21681                            .push(degraded_search_target(target, RebuildSearchReason::Missing));
21682                    } else {
21683                        return Err(err);
21684                    }
21685                }
21686            }
21687            SearchIndexState::Fresh => {}
21688            SearchIndexState::Stale { stale_files } => {
21689                if autoindex {
21690                    if let Err(err) = apply_search_index_update(root, target) {
21691                        if is_active_writer_lock_error(&err) {
21692                            degraded_targets.push(degraded_search_target(
21693                                target,
21694                                RebuildSearchReason::Stale { stale_files },
21695                            ));
21696                        } else {
21697                            return Err(err);
21698                        }
21699                    }
21700                } else {
21701                    stale_targets.push(RebuildSearchTarget {
21702                        label: target.label.clone(),
21703                        reason: RebuildSearchReason::Stale { stale_files },
21704                        reindex_cmd: target.reindex_cmd.clone(),
21705                    });
21706                }
21707            }
21708        }
21709    }
21710
21711    if stale_targets.is_empty() {
21712        return Ok(SearchPrecheck {
21713            targets,
21714            degraded_targets,
21715        });
21716    }
21717
21718    bail!(
21719        "tsift search aborted: {} \
21720         or re-run without `--no-autoindex`.",
21721        rebuild_search_targets_message(&stale_targets),
21722    );
21723}
21724
21725pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21726    if targets.is_empty() {
21727        return None;
21728    }
21729
21730    if targets
21731        .iter()
21732        .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21733    {
21734        Some(DegradedSearchMode::Exact)
21735    } else {
21736        Some(DegradedSearchMode::ReadOnly)
21737    }
21738}
21739
21740fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21741    if targets.len() == 1 {
21742        let target = &targets[0];
21743        return match target.reason {
21744            RebuildSearchReason::Missing => format!("{} is missing", target.label),
21745            RebuildSearchReason::Stale { stale_files } => {
21746                let file_suffix = if stale_files == 1 { "" } else { "s" };
21747                format!(
21748                    "{} is stale ({} file{})",
21749                    target.label, stale_files, file_suffix
21750                )
21751            }
21752        };
21753    }
21754
21755    let missing = targets
21756        .iter()
21757        .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21758        .count();
21759    let stale = targets.len().saturating_sub(missing);
21760    let mut parts = Vec::new();
21761    if stale > 0 {
21762        let suffix = if stale == 1 { "" } else { "es" };
21763        parts.push(format!("{stale} stale index{suffix}"));
21764    }
21765    if missing > 0 {
21766        let suffix = if missing == 1 { "" } else { "es" };
21767        parts.push(format!("{missing} missing index{suffix}"));
21768    }
21769    parts.join(", ")
21770}
21771
21772pub(crate) fn emit_degraded_search_note(
21773    targets: &[DegradedSearchTarget],
21774    mode: DegradedSearchMode,
21775) {
21776    let summary = degraded_search_targets_summary(targets);
21777    let reindex_cmd = &targets[0].reindex_cmd;
21778    match mode {
21779        DegradedSearchMode::ReadOnly => eprintln!(
21780            "note: active tsift writer detected; skipping autoindex because {}. \
21781             Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21782             Retry `{}` after the active writer finishes for fresh index results.",
21783            summary, reindex_cmd
21784        ),
21785        DegradedSearchMode::Exact => eprintln!(
21786            "note: active tsift writer detected; skipping autoindex because {}. \
21787             Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21788             for indexed symbol hits.",
21789            summary, reindex_cmd
21790        ),
21791    }
21792}
21793
21794fn search_timeout_message(
21795    timeout_secs: u64,
21796    strategy: &str,
21797    targets: &[SearchIndexTarget],
21798) -> Result<String> {
21799    let rebuild_targets = collect_rebuild_search_targets(targets)?;
21800    if rebuild_targets.is_empty() {
21801        return Ok(format!(
21802            "tsift search timed out after {}s (strategy: {}). \
21803             The search root looks fresh, so reindexing is unlikely to help. \
21804             Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21805             or try a different strategy.",
21806            timeout_secs, strategy,
21807        ));
21808    }
21809
21810    Ok(format!(
21811        "tsift search timed out after {}s (strategy: {}). {}",
21812        timeout_secs,
21813        strategy,
21814        rebuild_search_targets_message(&rebuild_targets),
21815    ))
21816}
21817
21818fn is_exact_preferring_query_char(ch: char) -> bool {
21819    matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21820}
21821
21822fn query_prefers_exact_search(query: &str) -> bool {
21823    let trimmed = query.trim();
21824    !trimmed.is_empty()
21825        && !trimmed.chars().any(char::is_whitespace)
21826        && trimmed.chars().any(|ch| ch.is_alphanumeric())
21827        && trimmed.chars().any(is_exact_preferring_query_char)
21828        && trimmed
21829            .chars()
21830            .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21831}
21832
21833pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21834    strategy.unwrap_or_else(|| {
21835        if query_prefers_exact_search(query) {
21836            "exact".to_string()
21837        } else {
21838            "lexical".to_string()
21839        }
21840    })
21841}
21842
21843pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21844    let mut files = Vec::new();
21845    if path.is_file() {
21846        files.push(path.to_path_buf());
21847        return Ok(files);
21848    }
21849    let walker = ignore::WalkBuilder::new(path)
21850        .hidden(true)
21851        .git_ignore(true)
21852        .build();
21853    for entry in walker {
21854        let entry = entry?;
21855        if entry.file_type().is_some_and(|ft| ft.is_file()) {
21856            let p = entry.path();
21857            if let Some(ext) = p.extension() {
21858                let ext = ext.to_string_lossy();
21859                if matches!(
21860                    ext.as_ref(),
21861                    "rs" | "py"
21862                        | "ts"
21863                        | "tsx"
21864                        | "js"
21865                        | "jsx"
21866                        | "kt"
21867                        | "kts"
21868                        | "zig"
21869                        | "gd"
21870                        | "sh"
21871                        | "bash"
21872                        | "zsh"
21873                ) {
21874                    files.push(p.to_path_buf());
21875                }
21876            }
21877        }
21878    }
21879    Ok(files)
21880}
21881
21882#[cfg(test)]
21883mod tests {
21884    use super::semantic_edit::{
21885        EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21886        markdown_section_spans,
21887    };
21888    use super::*;
21889    use tsift_memory::{MemoryEventKind, MemoryStore};
21890
21891    use std::cell::RefCell;
21892    use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21893
21894    #[test]
21895    fn graph_db_write_lock_serializes_concurrent_writers() {
21896        let dir = tempfile::tempdir().unwrap();
21897        let graph_db = dir.path().join(".tsift/graph.db");
21898        let short = Duration::from_millis(150);
21899
21900        let first = acquire_graph_db_write_lock_with_timeout(&graph_db, short)
21901            .expect("first writer acquires the lock");
21902        // A second acquire fails (bounded) while the first guard is held — this is
21903        // the cross-process mutual exclusion that protects refresh/snapshot-import.
21904        let second = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21905        assert!(
21906            second.is_err(),
21907            "a second writer must not acquire the graph-db write lock while it is held"
21908        );
21909        drop(first);
21910        // After release the lock is re-acquirable.
21911        let third = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21912        assert!(
21913            third.is_ok(),
21914            "graph-db write lock must be re-acquirable after release"
21915        );
21916    }
21917
21918    // #gdblockcover: `graph-db compact --apply` (DELETE + wal_checkpoint(TRUNCATE)
21919    // + VACUUM) must take the same advisory write lock as refresh/snapshot-import,
21920    // or its VACUUM races a concurrent refresh's WAL transaction. Hold the lock
21921    // externally and prove the compact blocks on it (rather than running unguarded
21922    // as it did before the fix), then completes once the lock is released.
21923    #[test]
21924    fn graph_db_compact_apply_blocks_on_held_write_lock() {
21925        let dir = setup_traversal_project();
21926        let session = dir.path().join("tasks/software/tsift.md");
21927        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
21928        let graph_db = graph_substrate_db_path(dir.path(), None);
21929
21930        let held = acquire_graph_db_write_lock(&graph_db).expect("hold writer lock");
21931
21932        let root = dir.path().to_path_buf();
21933        let handle = std::thread::Builder::new()
21934            .name("compact-apply".to_string())
21935            .stack_size(16 * 1024 * 1024)
21936            .spawn(move || {
21937                crate::commands::infra::cmd_graph_db_compact(
21938                    &root,
21939                    None,
21940                    true,
21941                    false,
21942                    false,
21943                    OutputFormat {
21944                        json_output: true,
21945                        compact: true,
21946                        pretty: false,
21947                        terse: false,
21948                        ultra_terse: false,
21949                        schema: false,
21950                        envelope: false,
21951                    },
21952                )
21953            })
21954            .unwrap();
21955
21956        // While the lock is held the compact cannot have finished — it must be
21957        // parked in the acquire loop. Before the fix it ran VACUUM unguarded and
21958        // would already be done here.
21959        std::thread::sleep(Duration::from_millis(300));
21960        assert!(
21961            !handle.is_finished(),
21962            "compact --apply must block on the held graph-db write lock, not run unguarded"
21963        );
21964
21965        drop(held);
21966        let result = handle.join().expect("compact thread joins");
21967        assert!(
21968            result.is_ok(),
21969            "compact --apply must succeed after the lock is released: {result:?}"
21970        );
21971    }
21972
21973    fn parse_cli<I, T>(itr: I) -> Cli
21974    where
21975        I: IntoIterator<Item = T> + Send + 'static,
21976        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21977    {
21978        std::thread::Builder::new()
21979            .name("cli-parse".to_string())
21980            .stack_size(16 * 1024 * 1024)
21981            .spawn(move || Cli::parse_from(itr))
21982            .unwrap()
21983            .join()
21984            .unwrap()
21985    }
21986
21987    fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21988    where
21989        I: IntoIterator<Item = T> + Send + 'static,
21990        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21991    {
21992        std::thread::Builder::new()
21993            .name("cli-try-parse".to_string())
21994            .stack_size(16 * 1024 * 1024)
21995            .spawn(move || Cli::try_parse_from(itr))
21996            .unwrap()
21997            .join()
21998            .unwrap()
21999    }
22000
22001    fn build_relative_search_budget_report(
22002        query: &str,
22003        strategy: &str,
22004        root: &Path,
22005        response: &sift::SearchResponse,
22006        symbol_hits: &[index::SymbolHit],
22007        budget: ResponseBudget,
22008        filters: &SearchFacetFilters,
22009    ) -> SearchBudgetReport {
22010        build_search_budget_report(SearchBudgetReportInput {
22011            query,
22012            strategy,
22013            root,
22014            response,
22015            symbol_hits,
22016            absolute: false,
22017            budget,
22018            filters,
22019        })
22020    }
22021
22022    #[derive(Default)]
22023    struct MemoryConvexGraphClient {
22024        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
22025        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
22026    }
22027
22028    impl ConvexGraphClient for MemoryConvexGraphClient {
22029        fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
22030            self.nodes
22031                .borrow_mut()
22032                .insert(row.external_id.clone(), row.clone());
22033            Ok(())
22034        }
22035
22036        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
22037            self.edges
22038                .borrow_mut()
22039                .insert(row.edge_key.clone(), row.clone());
22040            Ok(())
22041        }
22042
22043        fn delete_node_row(&self, external_id: &str) -> Result<usize> {
22044            Ok(usize::from(
22045                self.nodes.borrow_mut().remove(external_id).is_some(),
22046            ))
22047        }
22048
22049        fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
22050            Ok(usize::from(
22051                self.edges.borrow_mut().remove(edge_key).is_some(),
22052            ))
22053        }
22054
22055        fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
22056            Ok(self.nodes.borrow().get(external_id).cloned())
22057        }
22058
22059        fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
22060            Ok(self.nodes.borrow().values().cloned().collect())
22061        }
22062
22063        fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
22064            Ok(self.edges.borrow().values().cloned().collect())
22065        }
22066
22067        fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
22068            Ok(self
22069                .nodes
22070                .borrow()
22071                .values()
22072                .filter(|row| row.kind == kind)
22073                .cloned()
22074                .collect())
22075        }
22076
22077        fn outgoing_edge_rows(
22078            &self,
22079            from_external_id: &str,
22080            kind: Option<&str>,
22081        ) -> Result<Vec<ConvexEdgeRow>> {
22082            Ok(self
22083                .edges
22084                .borrow()
22085                .values()
22086                .filter(|row| row.from_external_id == from_external_id)
22087                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
22088                .cloned()
22089                .collect())
22090        }
22091    }
22092
22093    fn init_git_repo(path: &Path) {
22094        let status = std::process::Command::new("git")
22095            .args(["init"])
22096            .current_dir(path)
22097            .status()
22098            .unwrap();
22099        assert!(status.success(), "git init failed");
22100
22101        let status = std::process::Command::new("git")
22102            .args(["add", "."])
22103            .current_dir(path)
22104            .status()
22105            .unwrap();
22106        assert!(status.success(), "git add failed");
22107
22108        let status = std::process::Command::new("git")
22109            .args([
22110                "-c",
22111                "user.name=tsift-tests",
22112                "-c",
22113                "user.email=tsift-tests@example.com",
22114                "commit",
22115                "--quiet",
22116                "-m",
22117                "init",
22118            ])
22119            .current_dir(path)
22120            .status()
22121            .unwrap();
22122        assert!(status.success(), "git commit failed");
22123    }
22124
22125    fn write_empty_root_index(root: &Path) {
22126        let index_dir = root.join(".tsift");
22127        fs::create_dir_all(&index_dir).unwrap();
22128        fs::write(index_dir.join("index.db"), "").unwrap();
22129    }
22130
22131    fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
22132        if let Some(parent) = path.parent() {
22133            fs::create_dir_all(parent).unwrap();
22134        }
22135        let body = std::iter::repeat_n(line, lines)
22136            .collect::<Vec<_>>()
22137            .join("\n");
22138        fs::write(path, format!("{body}\n")).unwrap();
22139        path.to_path_buf()
22140    }
22141
22142    // --- build_token_capped_preview ---
22143
22144    #[test]
22145    fn token_capped_preview_returns_all_lines_when_under_cap() {
22146        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 1", "}"];
22147        let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
22148        assert!(!result.was_capped);
22149        assert_eq!(result.preview.len(), 3);
22150        assert_eq!(result.capped_end, 3);
22151    }
22152
22153    #[test]
22154    fn token_capped_preview_truncates_when_over_cap() {
22155        let lines: Vec<&str> = (0..200)
22156            .map(|_| "    let x = some_very_long_expression_here();")
22157            .collect();
22158        let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
22159        assert!(result.was_capped);
22160        assert!(result.preview.len() < 200);
22161        assert!(result.capped_end < 200);
22162    }
22163
22164    #[test]
22165    fn token_capped_preview_keeps_at_least_one_line() {
22166        let long_line: String = "x".repeat(8000);
22167        let lines: Vec<&str> = vec![&long_line];
22168        let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
22169        assert!(!result.was_capped);
22170        assert_eq!(result.preview.len(), 1);
22171    }
22172
22173    #[test]
22174    fn token_capped_preview_cap_at_boundary() {
22175        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22176        let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
22177        assert!(!result.was_capped);
22178        assert_eq!(result.preview.len(), 4);
22179    }
22180
22181    #[test]
22182    fn token_capped_preview_cap_just_over_boundary() {
22183        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22184        let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
22185        assert!(result.was_capped);
22186        assert_eq!(result.preview.len(), 3);
22187        assert_eq!(result.capped_end, 3);
22188    }
22189
22190    #[test]
22191    fn token_capped_preview_empty_lines() {
22192        let lines: Vec<&str> = vec![];
22193        let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
22194        assert!(!result.was_capped);
22195        assert!(result.preview.is_empty());
22196    }
22197
22198    #[test]
22199    fn token_capped_preview_per_line_truncation_applied() {
22200        let long_line = "x".repeat(500);
22201        let lines: Vec<&str> = vec![&long_line, "short"];
22202        let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
22203        assert!(!result.was_capped);
22204        assert_eq!(result.preview.len(), 2);
22205        assert!(result.preview[0].text.len() <= 23);
22206        assert!(result.preview[0].text.ends_with("..."));
22207    }
22208
22209    // --- classify_task ---
22210
22211    #[test]
22212    fn route_search_defaults_to_haiku() {
22213        let (tier, model) = classify_task("find all uses of authenticate");
22214        assert_eq!(tier, "haiku");
22215        assert!(
22216            model.contains("haiku"),
22217            "expected haiku model, got {}",
22218            model
22219        );
22220    }
22221
22222    #[test]
22223    fn route_edit_keywords_to_sonnet() {
22224        for kw in &[
22225            "edit the file",
22226            "fix the bug",
22227            "update the config",
22228            "remove dead code",
22229            "create a new module",
22230        ] {
22231            let (tier, _) = classify_task(kw);
22232            assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
22233        }
22234    }
22235
22236    #[test]
22237    fn route_architecture_keywords_to_opus() {
22238        for kw in &[
22239            "design the API",
22240            "architecture review",
22241            "plan the migration",
22242            "analyze the system",
22243            "evaluate trade-offs",
22244        ] {
22245            let (tier, _) = classify_task(kw);
22246            assert_eq!(tier, "opus", "expected opus for {:?}", kw);
22247        }
22248    }
22249
22250    #[test]
22251    fn route_architecture_beats_edit() {
22252        // "design and implement" — architecture signal wins (checked first)
22253        let (tier, _) = classify_task("design and implement the new auth service");
22254        assert_eq!(tier, "opus");
22255    }
22256
22257    #[test]
22258    fn cli_accepts_global_compact_flag() {
22259        let cli = parse_cli(["tsift", "--compact", "status"]);
22260        assert!(cli.compact);
22261        assert!(matches!(cli.command, Some(Commands::Status { .. })));
22262    }
22263
22264    #[test]
22265    fn summarize_diff_scope_matches_relative_directory() {
22266        let root = Path::new("/repo");
22267        let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
22268
22269        assert!(summarize_diff_matches_scope(
22270            Path::new("/repo/src/feature/main.rs"),
22271            &extract_scope
22272        ));
22273        assert!(!summarize_diff_matches_scope(
22274            Path::new("/repo/src/other/main.rs"),
22275            &extract_scope
22276        ));
22277    }
22278
22279    #[test]
22280    fn summarize_diff_scope_matches_relative_file() {
22281        let root = Path::new("/repo");
22282        let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
22283
22284        assert!(summarize_diff_matches_scope(
22285            Path::new("/repo/src/feature/main.rs"),
22286            &extract_scope
22287        ));
22288        assert!(!summarize_diff_matches_scope(
22289            Path::new("/repo/src/feature/lib.rs"),
22290            &extract_scope
22291        ));
22292    }
22293
22294    #[test]
22295    fn summarize_extract_scope_walks_relative_paths_from_root() {
22296        let dir = tempfile::tempdir().unwrap();
22297        let source_dir = dir.path().join("src");
22298        std::fs::create_dir_all(&source_dir).unwrap();
22299        let main_rs = source_dir.join("main.rs");
22300        std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
22301
22302        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
22303        let files = collect_source_files(&extract_scope).unwrap();
22304
22305        assert_eq!(files, vec![main_rs]);
22306    }
22307
22308    #[test]
22309    fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
22310        let dir = tempfile::tempdir().unwrap();
22311        let nested = dir.path().join("src/nested");
22312        std::fs::create_dir_all(&nested).unwrap();
22313        std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
22314        let nested_file = nested.join("main.rs");
22315        std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
22316
22317        let extract_base = resolve_extract_base(&nested).unwrap();
22318        let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
22319        let files = collect_source_files(&extract_scope).unwrap();
22320
22321        assert_eq!(extract_scope, nested);
22322        assert_eq!(files, vec![nested_file]);
22323    }
22324
22325    #[test]
22326    fn summarize_extract_base_uses_parent_of_file_path() {
22327        let dir = tempfile::tempdir().unwrap();
22328        let nested = dir.path().join("src/nested");
22329        std::fs::create_dir_all(&nested).unwrap();
22330        let file_path = nested.join("main.rs");
22331        std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
22332
22333        let extract_base = resolve_extract_base(&file_path).unwrap();
22334
22335        assert_eq!(extract_base, nested);
22336    }
22337
22338    #[test]
22339    fn summarize_extract_scope_normalizes_dotdot_segments() {
22340        let dir = tempfile::tempdir().unwrap();
22341        let source_dir = dir.path().join("src");
22342        std::fs::create_dir_all(&source_dir).unwrap();
22343
22344        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
22345
22346        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22347        assert!(summarize_diff_matches_scope(
22348            &source_dir.join("main.rs"),
22349            &extract_scope
22350        ));
22351    }
22352
22353    #[cfg(unix)]
22354    #[test]
22355    fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
22356        use std::os::unix::fs::symlink;
22357
22358        let dir = tempfile::tempdir().unwrap();
22359        let real_root = dir.path().join("real");
22360        let source_dir = real_root.join("src");
22361        std::fs::create_dir_all(&source_dir).unwrap();
22362        let symlink_scope = dir.path().join("scope-link");
22363        symlink(&source_dir, &symlink_scope).unwrap();
22364
22365        let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
22366
22367        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22368        assert!(summarize_diff_matches_scope(
22369            &source_dir.join("lib.rs"),
22370            &extract_scope
22371        ));
22372    }
22373
22374    #[test]
22375    fn summarize_diff_extract_includes_untracked_files() {
22376        let dir = tempfile::tempdir().unwrap();
22377        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22378        init_git_repo(dir.path());
22379
22380        let source_dir = dir.path().join("src");
22381        std::fs::create_dir_all(&source_dir).unwrap();
22382        let new_file = source_dir.join("new.rs");
22383        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22384
22385        let files = summarize::git_changed_files(dir.path()).unwrap();
22386
22387        assert_eq!(files.existing, vec![new_file]);
22388        assert!(files.deleted.is_empty());
22389    }
22390
22391    #[test]
22392    fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
22393        let dir = tempfile::tempdir().unwrap();
22394        let status = std::process::Command::new("git")
22395            .args(["init"])
22396            .current_dir(dir.path())
22397            .status()
22398            .unwrap();
22399        assert!(status.success(), "git init failed");
22400
22401        let source_dir = dir.path().join("src");
22402        std::fs::create_dir_all(&source_dir).unwrap();
22403        let new_file = source_dir.join("new.rs");
22404        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22405
22406        let files = summarize::git_changed_files(dir.path()).unwrap();
22407
22408        assert_eq!(files.existing, vec![new_file]);
22409        assert!(files.deleted.is_empty());
22410    }
22411
22412    #[test]
22413    fn summarize_diff_extract_tracks_deleted_files() {
22414        let dir = tempfile::tempdir().unwrap();
22415        let source_dir = dir.path().join("src");
22416        std::fs::create_dir_all(&source_dir).unwrap();
22417        let deleted_file = source_dir.join("gone.rs");
22418        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22419        init_git_repo(dir.path());
22420
22421        std::fs::remove_file(&deleted_file).unwrap();
22422
22423        let files = summarize::git_changed_files(dir.path()).unwrap();
22424
22425        assert!(files.existing.is_empty());
22426        assert_eq!(files.deleted, vec![deleted_file]);
22427    }
22428
22429    #[test]
22430    fn summarize_diff_extract_tracks_git_renames() {
22431        let dir = tempfile::tempdir().unwrap();
22432        let source_dir = dir.path().join("src");
22433        std::fs::create_dir_all(&source_dir).unwrap();
22434        let old_file = source_dir.join("old.rs");
22435        let new_file = source_dir.join("new.rs");
22436        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22437        init_git_repo(dir.path());
22438
22439        let status = std::process::Command::new("git")
22440            .args(["mv", "src/old.rs", "src/new.rs"])
22441            .current_dir(dir.path())
22442            .status()
22443            .unwrap();
22444        assert!(status.success(), "git mv failed");
22445
22446        let files = summarize::git_changed_files(dir.path()).unwrap();
22447
22448        assert_eq!(files.existing, vec![new_file]);
22449        assert_eq!(files.deleted, vec![old_file]);
22450    }
22451
22452    #[test]
22453    fn summarize_diff_extract_deletes_removed_summary_rows() {
22454        let dir = tempfile::tempdir().unwrap();
22455        let source_dir = dir.path().join("src");
22456        std::fs::create_dir_all(&source_dir).unwrap();
22457        let deleted_file = source_dir.join("gone.rs");
22458        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22459        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22460        init_git_repo(dir.path());
22461
22462        let summary_db =
22463            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22464        summary_db
22465            .insert(&summarize::Summary {
22466                id: 0,
22467                symbol_name: "stale".to_string(),
22468                file_path: "src/gone.rs".to_string(),
22469                content_hash: "hash1".to_string(),
22470                summary: "stale summary".to_string(),
22471                entities: None,
22472                relationships: None,
22473                concept_labels: None,
22474                extracted_at: "1700000000".to_string(),
22475                model: "test".to_string(),
22476                tokens_input: Some(100),
22477                tokens_output: Some(50),
22478            })
22479            .unwrap();
22480
22481        std::fs::remove_file(&deleted_file).unwrap();
22482
22483        cmd_summarize(
22484            None,
22485            None,
22486            Some(PathBuf::from("src")),
22487            true,
22488            false,
22489            dir.path(),
22490            false,
22491            true,
22492            false,
22493            false,
22494            false,
22495            None,
22496        )
22497        .unwrap();
22498
22499        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22500    }
22501
22502    #[test]
22503    fn summarize_diff_extract_deletes_renamed_summary_rows() {
22504        let dir = tempfile::tempdir().unwrap();
22505        let source_dir = dir.path().join("src");
22506        std::fs::create_dir_all(&source_dir).unwrap();
22507        let old_file = source_dir.join("old.rs");
22508        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22509        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22510        init_git_repo(dir.path());
22511
22512        let summary_db =
22513            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22514        summary_db
22515            .insert(&summarize::Summary {
22516                id: 0,
22517                symbol_name: "stale".to_string(),
22518                file_path: "src/old.rs".to_string(),
22519                content_hash: "hash1".to_string(),
22520                summary: "stale summary".to_string(),
22521                entities: None,
22522                relationships: None,
22523                concept_labels: None,
22524                extracted_at: "1700000000".to_string(),
22525                model: "test".to_string(),
22526                tokens_input: Some(100),
22527                tokens_output: Some(50),
22528            })
22529            .unwrap();
22530
22531        let status = std::process::Command::new("git")
22532            .args(["mv", "src/old.rs", "src/new.rs"])
22533            .current_dir(dir.path())
22534            .status()
22535            .unwrap();
22536        assert!(status.success(), "git mv failed");
22537
22538        cmd_summarize(
22539            None,
22540            None,
22541            Some(PathBuf::from("src")),
22542            true,
22543            false,
22544            dir.path(),
22545            false,
22546            true,
22547            false,
22548            false,
22549            false,
22550            None,
22551        )
22552        .unwrap();
22553
22554        assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
22555    }
22556
22557    #[test]
22558    fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
22559        let dir = tempfile::tempdir().unwrap();
22560        let source_dir = dir.path().join("src");
22561        std::fs::create_dir_all(&source_dir).unwrap();
22562        let deleted_file = source_dir.join("gone.rs");
22563        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22564
22565        let summary_db =
22566            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22567        summary_db
22568            .insert(&summarize::Summary {
22569                id: 0,
22570                symbol_name: "stale".to_string(),
22571                file_path: "src/gone.rs".to_string(),
22572                content_hash: "hash1".to_string(),
22573                summary: "stale summary".to_string(),
22574                entities: None,
22575                relationships: None,
22576                concept_labels: None,
22577                extracted_at: "1700000000".to_string(),
22578                model: "test".to_string(),
22579                tokens_input: Some(100),
22580                tokens_output: Some(50),
22581            })
22582            .unwrap();
22583
22584        std::fs::remove_file(&deleted_file).unwrap();
22585
22586        cmd_summarize(
22587            None,
22588            None,
22589            Some(PathBuf::from("src")),
22590            false,
22591            false,
22592            dir.path(),
22593            false,
22594            true,
22595            false,
22596            false,
22597            false,
22598            None,
22599        )
22600        .unwrap();
22601
22602        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22603    }
22604
22605    #[test]
22606    fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
22607        let dir = tempfile::tempdir().unwrap();
22608        let source_dir = dir.path().join("src");
22609        std::fs::create_dir_all(&source_dir).unwrap();
22610        let file = source_dir.join("lib.rs");
22611        std::fs::write(&file, "fn helper() {}\n").unwrap();
22612
22613        let content = std::fs::read(&file).unwrap();
22614        let summary_db =
22615            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22616        summary_db
22617            .insert(&summarize::Summary {
22618                id: 0,
22619                symbol_name: "lib.rs".to_string(),
22620                file_path: "src/lib.rs".to_string(),
22621                content_hash: summarize::content_hash(&content),
22622                summary: "cached summary".to_string(),
22623                entities: None,
22624                relationships: None,
22625                concept_labels: None,
22626                extracted_at: "1700000000".to_string(),
22627                model: "test".to_string(),
22628                tokens_input: Some(100),
22629                tokens_output: Some(50),
22630            })
22631            .unwrap();
22632        drop(summary_db);
22633
22634        let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
22635        let _lock = hold_writer_lock(&lock_path);
22636
22637        let err = cmd_summarize(
22638            None,
22639            None,
22640            Some(PathBuf::from("src")),
22641            false,
22642            false,
22643            dir.path(),
22644            false,
22645            true,
22646            false,
22647            false,
22648            false,
22649            None,
22650        )
22651        .unwrap_err();
22652        let message = err.to_string();
22653
22654        assert!(message.contains("another tsift summarize extractor is already active"));
22655        assert!(message.contains("tsift summarize --extract"));
22656    }
22657
22658    #[test]
22659    fn summarize_stats_fails_closed_when_cache_missing() {
22660        let dir = tempfile::tempdir().unwrap();
22661        let err = cmd_summarize(
22662            None,
22663            None,
22664            None,
22665            false,
22666            true,
22667            dir.path(),
22668            false,
22669            false,
22670            false,
22671            false,
22672            false,
22673            None,
22674        )
22675        .unwrap_err();
22676
22677        assert!(
22678            err.to_string().contains("no summaries.db found"),
22679            "got: {err}"
22680        );
22681        assert!(!dir.path().join(".tsift/summaries.db").exists());
22682    }
22683
22684    #[test]
22685    fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22686        let dir = tempfile::tempdir().unwrap();
22687        let summary_db =
22688            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22689        summary_db
22690            .insert(&summarize::Summary {
22691                id: 0,
22692                symbol_name: "alpha_helper".to_string(),
22693                file_path: "src/lib.rs".to_string(),
22694                content_hash: "hash1".to_string(),
22695                summary: "cached summary".to_string(),
22696                entities: None,
22697                relationships: None,
22698                concept_labels: None,
22699                extracted_at: "1700000000".to_string(),
22700                model: "claude-haiku-4-5-20251001".to_string(),
22701                tokens_input: Some(100),
22702                tokens_output: Some(40),
22703            })
22704            .unwrap();
22705        drop(summary_db);
22706        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22707
22708        let result = cmd_summarize(
22709            None,
22710            None,
22711            None,
22712            false,
22713            true,
22714            dir.path(),
22715            false,
22716            false,
22717            false,
22718            false,
22719            false,
22720            None,
22721        );
22722
22723        assert!(result.is_ok());
22724    }
22725
22726    #[test]
22727    fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22728        let dir = tempfile::tempdir().unwrap();
22729        let summary_db =
22730            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22731        summary_db
22732            .insert(&summarize::Summary {
22733                id: 0,
22734                symbol_name: "alpha_helper".to_string(),
22735                file_path: "src/lib.rs".to_string(),
22736                content_hash: "hash1".to_string(),
22737                summary: "cached summary".to_string(),
22738                entities: None,
22739                relationships: None,
22740                concept_labels: None,
22741                extracted_at: "1700000000".to_string(),
22742                model: "claude-haiku-4-5-20251001".to_string(),
22743                tokens_input: Some(100),
22744                tokens_output: Some(40),
22745            })
22746            .unwrap();
22747        drop(summary_db);
22748        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22749
22750        let result = cmd_summarize(
22751            Some("alpha_helper".to_string()),
22752            None,
22753            None,
22754            false,
22755            false,
22756            dir.path(),
22757            false,
22758            true,
22759            false,
22760            false,
22761            false,
22762            None,
22763        );
22764
22765        assert!(result.is_ok());
22766    }
22767
22768    #[test]
22769    fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
22770        let dir = tempfile::tempdir().unwrap();
22771        let nested = dir.path().join("src/nested");
22772        std::fs::create_dir_all(&nested).unwrap();
22773
22774        let summary_db =
22775            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22776        summary_db
22777            .insert(&summarize::Summary {
22778                id: 0,
22779                symbol_name: "alpha_helper".to_string(),
22780                file_path: "src/lib.rs".to_string(),
22781                content_hash: "hash1".to_string(),
22782                summary: "cached summary".to_string(),
22783                entities: None,
22784                relationships: None,
22785                concept_labels: None,
22786                extracted_at: "1700000000".to_string(),
22787                model: "claude-haiku-4-5-20251001".to_string(),
22788                tokens_input: Some(100),
22789                tokens_output: Some(40),
22790            })
22791            .unwrap();
22792
22793        let result = cmd_summarize(
22794            Some("alpha_helper".to_string()),
22795            None,
22796            None,
22797            false,
22798            false,
22799            &nested,
22800            false,
22801            true,
22802            false,
22803            false,
22804            false,
22805            None,
22806        );
22807
22808        assert!(result.is_ok());
22809        assert!(!nested.join(".tsift/summaries.db").exists());
22810    }
22811
22812    #[test]
22813    fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22814        let dir = tempfile::tempdir().unwrap();
22815        std::fs::write(
22816            dir.path().join(".gitmodules"),
22817            r#"[submodule "src/alpha"]
22818	path = src/alpha
22819	url = https://example.com/alpha
22820[submodule "src/beta"]
22821	path = src/beta
22822	url = https://example.com/beta
22823"#,
22824        )
22825        .unwrap();
22826
22827        let alpha_root = dir.path().join("src/alpha");
22828        let beta_root = dir.path().join("src/beta");
22829        std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22830        std::fs::create_dir_all(beta_root.join("src")).unwrap();
22831        std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22832        std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22833        std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22834        let beta_file = beta_root.join("src/lib.rs");
22835        std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22836        std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22837        std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22838
22839        let context = find_symbols_db_for_file(dir.path(), &beta_file)
22840            .unwrap()
22841            .expect("expected matching scoped index");
22842
22843        assert_eq!(
22844            context.db_path,
22845            dir.path().join(".tsift/indexes/beta/index.db")
22846        );
22847        assert_eq!(context.source_root, beta_root);
22848    }
22849
22850    // --- apply_edit_op ---
22851
22852    fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22853        EditOp {
22854            file: PathBuf::from("dummy.txt"),
22855            old: old.to_string(),
22856            new: new.to_string(),
22857            replace_all,
22858        }
22859    }
22860
22861    #[test]
22862    fn edit_replaces_single_occurrence() {
22863        let content = "hello world";
22864        let op = make_op("world", "rust", false);
22865        let (result, count) = apply_edit_op(content, &op).unwrap();
22866        assert_eq!(result, "hello rust");
22867        assert_eq!(count, 1);
22868    }
22869
22870    #[test]
22871    fn edit_replace_all_replaces_every_occurrence() {
22872        let content = "foo foo foo";
22873        let op = make_op("foo", "bar", true);
22874        let (result, count) = apply_edit_op(content, &op).unwrap();
22875        assert_eq!(result, "bar bar bar");
22876        assert_eq!(count, 3);
22877    }
22878
22879    #[test]
22880    fn edit_fails_when_old_not_found() {
22881        let content = "hello world";
22882        let op = make_op("missing", "x", false);
22883        assert!(apply_edit_op(content, &op).is_err());
22884    }
22885
22886    #[test]
22887    fn edit_fails_when_ambiguous_without_replace_all() {
22888        let content = "foo foo";
22889        let op = make_op("foo", "bar", false);
22890        let err = apply_edit_op(content, &op).unwrap_err();
22891        assert!(err.to_string().contains("2 times"), "got: {}", err);
22892    }
22893
22894    #[test]
22895    fn edit_fails_when_old_equals_new() {
22896        let content = "hello";
22897        let op = make_op("hello", "hello", false);
22898        assert!(apply_edit_op(content, &op).is_err());
22899    }
22900
22901    #[test]
22902    fn edit_batch_rolls_back_when_later_swap_fails() {
22903        let dir = tempfile::tempdir().unwrap();
22904        let alpha = dir.path().join("alpha.txt");
22905        let beta = dir.path().join("beta.txt");
22906        fs::write(&alpha, "alpha old\n").unwrap();
22907        fs::write(&beta, "beta old\n").unwrap();
22908
22909        let batch = EditBatch {
22910            edits: vec![
22911                EditOp {
22912                    file: alpha.clone(),
22913                    old: "old".to_string(),
22914                    new: "new".to_string(),
22915                    replace_all: false,
22916                },
22917                EditOp {
22918                    file: beta.clone(),
22919                    old: "old".to_string(),
22920                    new: "new".to_string(),
22921                    replace_all: false,
22922                },
22923            ],
22924        };
22925
22926        let plan = build_edit_plan(&batch).unwrap();
22927        let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22928            if commit_index == 1 {
22929                bail!("simulated swap failure");
22930            }
22931            Ok(())
22932        }) {
22933            Ok(_) => panic!("expected simulated swap failure"),
22934            Err(err) => err,
22935        };
22936
22937        assert!(err.to_string().contains("simulated swap failure"));
22938        assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22939        assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22940    }
22941
22942    // --- SQL introspection ---
22943
22944    fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22945        let tmp = tempfile::NamedTempFile::new().unwrap();
22946        let conn = Connection::open(tmp.path()).unwrap();
22947        conn.execute_batch(
22948            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22949             INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22950             INSERT INTO users VALUES (2, 'Bob', NULL);
22951             CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22952                 FOREIGN KEY(user_id) REFERENCES users(id));
22953             INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22954             INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22955             INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22956        ).unwrap();
22957        (tmp, conn)
22958    }
22959
22960    // --- rewrite_command ---
22961
22962    #[test]
22963    fn rewrite_rg_simple_pattern() {
22964        let result = rewrite_command("rg authenticate");
22965        assert_eq!(
22966            result,
22967            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22968        );
22969    }
22970
22971    #[test]
22972    fn rewrite_rg_with_path() {
22973        let result = rewrite_command("rg authenticate src/");
22974        assert_eq!(
22975            result,
22976            Some(
22977                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22978                    .to_string()
22979            )
22980        );
22981    }
22982
22983    #[test]
22984    fn rewrite_rg_with_flags_ignored() {
22985        let result = rewrite_command("rg -i authenticate src/");
22986        assert_eq!(
22987            result,
22988            Some(
22989                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22990                    .to_string()
22991            )
22992        );
22993    }
22994
22995    #[test]
22996    fn rewrite_rg_with_type_flag() {
22997        // -t rs takes a value, should be skipped; pattern is next positional
22998        let result = rewrite_command("rg -t rs authenticate");
22999        assert_eq!(
23000            result,
23001            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
23002        );
23003    }
23004
23005    #[test]
23006    fn rewrite_rg_pipe_passthrough() {
23007        // Pipe chains can't be translated — pass through
23008        let result = rewrite_command("rg authenticate | head -5");
23009        assert_eq!(result, None);
23010    }
23011
23012    #[test]
23013    fn rewrite_rg_files_passthrough() {
23014        let result = rewrite_command("rg --files src/tsift .agent-doc logs");
23015        assert_eq!(result, None);
23016    }
23017
23018    #[test]
23019    fn rewrite_find_passthrough() {
23020        let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
23021        assert_eq!(result, None);
23022    }
23023
23024    #[test]
23025    fn rewrite_grep_recursive() {
23026        let result = rewrite_command("grep -r authenticate src/");
23027        assert_eq!(
23028            result,
23029            Some(
23030                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
23031                    .to_string()
23032            )
23033        );
23034    }
23035
23036    #[test]
23037    fn rewrite_grep_non_recursive_passthrough() {
23038        let result = rewrite_command("grep authenticate file.txt");
23039        assert_eq!(result, None);
23040    }
23041
23042    #[test]
23043    fn rewrite_tsift_passthrough() {
23044        let result = rewrite_command("tsift search \"foo\"");
23045        assert_eq!(result, Some("tsift search \"foo\"".to_string()));
23046    }
23047
23048    #[test]
23049    fn rewrite_run_tsift_search_disables_timeout_by_default() {
23050        let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
23051        assert_eq!(
23052            result,
23053            "tsift search hookcaps --exact --path /tmp/x --timeout 0"
23054        );
23055    }
23056
23057    #[test]
23058    fn rewrite_run_preserves_explicit_search_timeout() {
23059        let result = effective_rewrite_run_command(
23060            "tsift search hookcaps --exact --path /tmp/x --timeout 5",
23061        );
23062        assert_eq!(
23063            result,
23064            "tsift search hookcaps --exact --path /tmp/x --timeout 5"
23065        );
23066    }
23067
23068    #[test]
23069    fn rewrite_unrelated_passthrough() {
23070        let result = rewrite_command("echo cargo build");
23071        assert_eq!(result, None);
23072    }
23073
23074    #[test]
23075    fn rewrite_rg_quoted_pattern() {
23076        let result = rewrite_command("rg \"fn main\"");
23077        assert_eq!(
23078            result,
23079            Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
23080        );
23081    }
23082
23083    #[test]
23084    fn rewrite_git_diff_to_diff_digest() {
23085        let result = rewrite_command("git diff");
23086        assert_eq!(result, Some("tsift diff-digest .".to_string()));
23087    }
23088
23089    #[test]
23090    fn rewrite_git_diff_cached_to_diff_digest() {
23091        let result = rewrite_command("git diff --cached");
23092        assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
23093    }
23094
23095    #[test]
23096    fn rewrite_git_diff_with_path_to_diff_digest() {
23097        let result = rewrite_command("git diff -- src/");
23098        assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
23099    }
23100
23101    #[test]
23102    fn rewrite_git_diff_with_revision_passthrough() {
23103        let result = rewrite_command("git diff HEAD~1");
23104        assert_eq!(result, None);
23105    }
23106
23107    #[test]
23108    fn rewrite_git_show_to_revision_diff_digest() {
23109        let result = rewrite_command("git show HEAD~1");
23110        assert_eq!(
23111            result,
23112            Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
23113        );
23114    }
23115
23116    #[test]
23117    fn rewrite_git_log_patch_history_to_revision_diff_digest() {
23118        let result = rewrite_command("git log -p -1 HEAD~2");
23119        assert_eq!(
23120            result,
23121            Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
23122        );
23123    }
23124
23125    #[test]
23126    fn rewrite_cat_long_agent_doc_session_to_session_digest() {
23127        let dir = tempfile::tempdir().unwrap();
23128        let session = dir.path().join("tsift.md");
23129        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23130        for index in 0..90 {
23131            body.push_str(&format!("❯ prompt {index}?\n"));
23132        }
23133        fs::write(&session, body).unwrap();
23134
23135        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23136        assert_eq!(
23137            result,
23138            Some(format!(
23139                "tsift session-digest --path {} --input {} --source markdown",
23140                shell_quote(&resolve_digest_context_path(&session)),
23141                shell_quote(session.to_str().unwrap())
23142            ))
23143        );
23144    }
23145
23146    #[test]
23147    fn rewrite_head_long_claude_jsonl_to_session_digest() {
23148        let dir = tempfile::tempdir().unwrap();
23149        let session = dir.path().join("session.jsonl");
23150        let line =
23151            r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
23152        let body = std::iter::repeat_n(line, 120)
23153            .collect::<Vec<_>>()
23154            .join("\n");
23155        fs::write(&session, format!("{body}\n")).unwrap();
23156
23157        let result = rewrite_command(&format!(
23158            "head -n 120 {}",
23159            shell_quote(session.to_str().unwrap())
23160        ));
23161        assert_eq!(
23162            result,
23163            Some(format!(
23164                "tsift session-digest --path {} --input {} --source claude-jsonl",
23165                shell_quote(&resolve_digest_context_path(&session)),
23166                shell_quote(session.to_str().unwrap())
23167            ))
23168        );
23169    }
23170
23171    #[test]
23172    fn rewrite_head_long_codex_jsonl_to_session_digest() {
23173        let dir = tempfile::tempdir().unwrap();
23174        let session = dir.path().join("codex.jsonl");
23175        let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
23176        let body = std::iter::repeat_n(line, 120)
23177            .collect::<Vec<_>>()
23178            .join("\n");
23179        fs::write(&session, format!("{body}\n")).unwrap();
23180
23181        let result = rewrite_command(&format!(
23182            "head -n 120 {}",
23183            shell_quote(session.to_str().unwrap())
23184        ));
23185        assert_eq!(
23186            result,
23187            Some(format!(
23188                "tsift session-digest --path {} --input {} --source codex-jsonl",
23189                shell_quote(&resolve_digest_context_path(&session)),
23190                shell_quote(session.to_str().unwrap())
23191            ))
23192        );
23193    }
23194
23195    #[test]
23196    fn rewrite_small_transcript_window_passthrough() {
23197        let dir = tempfile::tempdir().unwrap();
23198        let session = dir.path().join("session.jsonl");
23199        let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
23200        let body = std::iter::repeat_n(line, 120)
23201            .collect::<Vec<_>>()
23202            .join("\n");
23203        fs::write(&session, format!("{body}\n")).unwrap();
23204
23205        let result = rewrite_command(&format!(
23206            "tail -n 20 {}",
23207            shell_quote(session.to_str().unwrap())
23208        ));
23209        assert_eq!(result, None);
23210    }
23211
23212    #[test]
23213    fn rewrite_sed_large_agent_doc_range_to_session_digest() {
23214        let dir = tempfile::tempdir().unwrap();
23215        let session = dir.path().join("tsift.md");
23216        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23217        for index in 0..120 {
23218            body.push_str(&format!("### Re: topic {index}\n"));
23219        }
23220        fs::write(&session, body).unwrap();
23221
23222        let result = rewrite_command(&format!(
23223            "sed -n '1,120p' {}",
23224            shell_quote(session.to_str().unwrap())
23225        ));
23226        assert_eq!(
23227            result,
23228            Some(format!(
23229                "tsift session-digest --path {} --input {} --source markdown",
23230                shell_quote(&resolve_digest_context_path(&session)),
23231                shell_quote(session.to_str().unwrap())
23232            ))
23233        );
23234    }
23235
23236    #[test]
23237    fn rewrite_cat_large_agent_doc_log_to_session_digest() {
23238        let dir = tempfile::tempdir().unwrap();
23239        let session = dir.path().join("tsift.log");
23240        let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
23241        let body = std::iter::repeat_n(line, 120)
23242            .collect::<Vec<_>>()
23243            .join("\n");
23244        fs::write(&session, format!("{body}\n")).unwrap();
23245
23246        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23247        assert_eq!(
23248            result,
23249            Some(format!(
23250                "tsift session-digest --path {} --input {} --source agent-doc-log",
23251                shell_quote(&resolve_digest_context_path(&session)),
23252                shell_quote(session.to_str().unwrap())
23253            ))
23254        );
23255    }
23256
23257    #[test]
23258    fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
23259        let dir = tempfile::tempdir().unwrap();
23260        fs::write(
23261            dir.path().join(".gitmodules"),
23262            r#"[submodule "src/tsift"]
23263	path = src/tsift
23264	url = https://example.com/tsift
23265"#,
23266        )
23267        .unwrap();
23268        let submodule = dir.path().join("src/tsift");
23269        fs::create_dir_all(submodule.join("tasks")).unwrap();
23270        fs::write(
23271            submodule.join(".git"),
23272            "gitdir: ../../.git/modules/src/tsift\n",
23273        )
23274        .unwrap();
23275        let session = submodule.join("tasks/plan.md");
23276        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23277        for index in 0..90 {
23278            body.push_str(&format!("❯ prompt {index}?\n"));
23279        }
23280        fs::write(&session, body).unwrap();
23281
23282        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23283
23284        assert_eq!(
23285            result,
23286            Some(format!(
23287                "tsift session-digest --path {} --input {} --source markdown",
23288                shell_quote(submodule.to_str().unwrap()),
23289                shell_quote(session.to_str().unwrap())
23290            ))
23291        );
23292    }
23293
23294    #[test]
23295    fn rewrite_regular_markdown_read_passthrough() {
23296        let dir = tempfile::tempdir().unwrap();
23297        let readme = dir.path().join("README.md");
23298        let body = std::iter::repeat_n("plain markdown", 120)
23299            .collect::<Vec<_>>()
23300            .join("\n");
23301        fs::write(&readme, format!("{body}\n")).unwrap();
23302
23303        let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
23304        assert_eq!(result, None);
23305    }
23306
23307    #[test]
23308    fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
23309        let dir = tempfile::tempdir().unwrap();
23310        write_empty_root_index(dir.path());
23311        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23312
23313        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23314
23315        assert_eq!(
23316            result,
23317            Some(format!(
23318                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
23319                shell_quote(&dir.path().to_string_lossy())
23320            ))
23321        );
23322    }
23323
23324    #[test]
23325    fn rewrite_head_small_source_window_passthrough() {
23326        let dir = tempfile::tempdir().unwrap();
23327        write_empty_root_index(dir.path());
23328        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23329
23330        let result = rewrite_command(&format!(
23331            "head -n 20 {}",
23332            shell_quote(source.to_str().unwrap())
23333        ));
23334
23335        assert_eq!(result, None);
23336    }
23337
23338    #[test]
23339    fn rewrite_sed_large_source_range_to_source_read() {
23340        let dir = tempfile::tempdir().unwrap();
23341        write_empty_root_index(dir.path());
23342        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23343
23344        let result = rewrite_command(&format!(
23345            "sed -n '40,160p' {}",
23346            shell_quote(source.to_str().unwrap())
23347        ));
23348
23349        assert_eq!(
23350            result,
23351            Some(format!(
23352                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
23353                shell_quote(&dir.path().to_string_lossy())
23354            ))
23355        );
23356    }
23357
23358    #[test]
23359    fn rewrite_tail_large_source_window_preserves_tail_anchor() {
23360        let dir = tempfile::tempdir().unwrap();
23361        write_empty_root_index(dir.path());
23362        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23363
23364        let result = rewrite_command(&format!(
23365            "tail -n 120 {}",
23366            shell_quote(source.to_str().unwrap())
23367        ));
23368
23369        assert_eq!(
23370            result,
23371            Some(format!(
23372                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
23373                shell_quote(&dir.path().to_string_lossy())
23374            ))
23375        );
23376    }
23377
23378    #[test]
23379    fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
23380        let dir = tempfile::tempdir().unwrap();
23381        write_empty_root_index(dir.path());
23382        let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
23383
23384        let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
23385
23386        assert_eq!(result, None);
23387    }
23388
23389    #[test]
23390    fn rewrite_large_source_read_passthrough_without_index() {
23391        let dir = tempfile::tempdir().unwrap();
23392        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23393
23394        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23395
23396        assert_eq!(result, None);
23397    }
23398
23399    #[test]
23400    fn rewrite_cargo_test_to_digest_runner() {
23401        let result = rewrite_command("cargo test --lib");
23402        assert_eq!(
23403            result,
23404            Some(
23405                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
23406            )
23407        );
23408    }
23409
23410    #[test]
23411    fn rewrite_pytest_to_digest_runner() {
23412        let result = rewrite_command("pytest -q tests/test_cli.py");
23413        assert_eq!(
23414            result,
23415            Some(
23416                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
23417            )
23418        );
23419    }
23420
23421    #[test]
23422    fn rewrite_python_m_pytest_to_digest_runner() {
23423        let result = rewrite_command("python -m pytest tests/test_cli.py");
23424        assert_eq!(
23425            result,
23426            Some(
23427                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
23428            )
23429        );
23430    }
23431
23432    #[test]
23433    fn rewrite_cargo_build_to_log_digest_runner() {
23434        let result = rewrite_command("cargo build --release");
23435        assert_eq!(
23436            result,
23437            Some(
23438                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
23439            )
23440        );
23441    }
23442
23443    #[test]
23444    fn rewrite_cargo_install_to_log_digest_runner() {
23445        let result = rewrite_command("cargo install --path . --force");
23446        assert_eq!(
23447            result,
23448            Some(
23449                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
23450            )
23451        );
23452    }
23453
23454    #[test]
23455    fn rewrite_metacharacter_command_passthrough() {
23456        let result = rewrite_command("cargo test | head");
23457        assert_eq!(result, None);
23458    }
23459
23460    #[test]
23461    fn rewrite_output_cap_detects_search_even_with_global_flag() {
23462        let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
23463        assert_eq!(cap.max_lines, 50);
23464        assert_eq!(cap.strip_prefix, Some("Strategy:"));
23465    }
23466
23467    #[test]
23468    fn rewrite_output_cap_skips_structured_output() {
23469        assert!(rewrite_output_cap("tsift search foo --json").is_none());
23470        assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
23471        assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
23472    }
23473
23474    #[test]
23475    fn rewrite_output_format_forwards_envelope_to_digest_runner() {
23476        let command = rewrite_command("cargo test --lib").expect("rewrite");
23477        let forwarded = apply_rewrite_output_format(
23478            &command,
23479            OutputFormat {
23480                json_output: true,
23481                compact: false,
23482                pretty: false,
23483                terse: false,
23484                ultra_terse: false,
23485                schema: false,
23486                envelope: true,
23487            },
23488        );
23489        assert_eq!(
23490            forwarded,
23491            "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
23492        );
23493    }
23494
23495    #[test]
23496    fn rewrite_output_format_forwards_json_when_requested() {
23497        let command = rewrite_command("cargo build --release").expect("rewrite");
23498        let forwarded = apply_rewrite_output_format(
23499            &command,
23500            OutputFormat {
23501                json_output: true,
23502                compact: false,
23503                pretty: true,
23504                terse: false,
23505                ultra_terse: false,
23506                schema: false,
23507                envelope: false,
23508            },
23509        );
23510        assert_eq!(
23511            forwarded,
23512            "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
23513        );
23514    }
23515
23516    #[test]
23517    fn output_cap_strips_search_header_and_truncates() {
23518        let capped = apply_output_cap(
23519            b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
23520            OutputCap {
23521                max_lines: 2,
23522                strip_prefix: Some("Strategy:"),
23523            },
23524        );
23525        assert_eq!(
23526            capped,
23527            "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
23528        );
23529    }
23530
23531    #[test]
23532    fn sql_schema_overview_lists_tables() {
23533        let (_tmp, conn) = setup_test_db();
23534        let tables = schema_overview(&conn).unwrap();
23535        let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
23536        assert_eq!(names, &["posts", "users"]);
23537    }
23538
23539    #[test]
23540    fn sql_schema_overview_row_counts() {
23541        let (_tmp, conn) = setup_test_db();
23542        let tables = schema_overview(&conn).unwrap();
23543        let users = tables.iter().find(|t| t.name == "users").unwrap();
23544        let posts = tables.iter().find(|t| t.name == "posts").unwrap();
23545        assert_eq!(users.row_count, 2);
23546        assert_eq!(posts.row_count, 3);
23547    }
23548
23549    #[test]
23550    fn sql_table_columns_metadata() {
23551        let (_tmp, conn) = setup_test_db();
23552        let cols = table_columns(&conn, "users").unwrap();
23553        assert_eq!(cols.len(), 3);
23554        assert_eq!(cols[0].name, "id");
23555        assert!(cols[0].pk);
23556        assert_eq!(cols[1].name, "name");
23557        assert!(cols[1].notnull);
23558        assert_eq!(cols[2].name, "email");
23559        assert!(!cols[2].notnull);
23560    }
23561
23562    #[test]
23563    fn sql_execute_query_returns_rows() {
23564        let (_tmp, conn) = setup_test_db();
23565        let (columns, rows) =
23566            execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
23567        assert_eq!(columns, &["name", "email"]);
23568        assert_eq!(rows.len(), 2);
23569        assert_eq!(rows[0][0], serde_json::json!("Alice"));
23570        assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
23571        assert_eq!(rows[1][1], serde_json::Value::Null);
23572    }
23573
23574    #[test]
23575    fn sql_execute_query_aggregate() {
23576        let (_tmp, conn) = setup_test_db();
23577        let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
23578        assert_eq!(columns, &["cnt"]);
23579        assert_eq!(rows[0][0], serde_json::json!(3));
23580    }
23581
23582    #[test]
23583    fn sql_execute_query_join() {
23584        let (_tmp, conn) = setup_test_db();
23585        let (_cols, rows) = execute_query(
23586            &conn,
23587            "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
23588        )
23589        .unwrap();
23590        assert_eq!(rows.len(), 3);
23591        assert_eq!(rows[0][0], serde_json::json!("Alice"));
23592        assert_eq!(rows[2][0], serde_json::json!("Bob"));
23593    }
23594
23595    #[test]
23596    fn sql_open_db_read_only() {
23597        let (tmp, _conn) = setup_test_db();
23598        drop(_conn);
23599        let ro_conn = open_db(tmp.path()).unwrap();
23600        let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
23601        assert!(result.is_err(), "read-only connection should reject writes");
23602    }
23603
23604    #[test]
23605    fn sql_empty_table_schema() {
23606        let tmp = tempfile::NamedTempFile::new().unwrap();
23607        let conn = Connection::open(tmp.path()).unwrap();
23608        conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
23609            .unwrap();
23610        let tables = schema_overview(&conn).unwrap();
23611        assert_eq!(tables[0].row_count, 0);
23612        assert_eq!(tables[0].columns.len(), 2);
23613    }
23614
23615    // --- graph command ---
23616
23617    fn setup_graph_index() -> tempfile::TempDir {
23618        let dir = tempfile::tempdir().unwrap();
23619        std::fs::write(
23620            dir.path().join("main.rs"),
23621            "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
23622        )
23623        .unwrap();
23624        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23625        db.apply_changes(dir.path()).unwrap();
23626        dir
23627    }
23628
23629    fn setup_traversal_project() -> tempfile::TempDir {
23630        let dir = setup_graph_index();
23631        let task_dir = dir.path().join("tasks/software");
23632        std::fs::create_dir_all(&task_dir).unwrap();
23633        std::fs::write(
23634            task_dir.join("tsift.md"),
23635            r#"---
23636agent_doc_session: tsift-v0.1
23637agent_doc_format: template
23638---
23639
23640## Exchange
23641
23642<!-- agent:exchange patch=append -->
23643❯ do [#kgnv]
23644Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
23645<!-- /agent:exchange -->
23646
23647<!-- agent:queue -->
23648dispatch #spec-test-build-install-commit-push
23649- do [#kgnv]
23650<!-- /agent:queue -->
23651
23652## Backlog
23653
23654<!-- agent:backlog -->
23655- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
23656<!-- /agent:backlog -->
23657"#,
23658        )
23659        .unwrap();
23660        dir
23661    }
23662
23663    fn resolve_ast_span_node<'a>(
23664        graph: &'a TraversalGraphBuild,
23665        label: &str,
23666        symbol_kind: &str,
23667    ) -> &'a TraversalNode {
23668        graph
23669            .nodes
23670            .values()
23671            .find(|node| {
23672                node.kind == "ast_span"
23673                    && node.label == label
23674                    && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
23675            })
23676            .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
23677    }
23678
23679    fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
23680        let dir = tempfile::tempdir().unwrap();
23681        std::fs::write(
23682            dir.path().join("rust.rs"),
23683            r#"mod fixture_nav_rust_mod {
23684    pub fn fixture_nav_rust_helper() {}
23685    pub fn fixture_nav_rust_entry() {
23686        fixture_nav_rust_helper();
23687    }
23688}
23689"#,
23690        )
23691        .unwrap();
23692        std::fs::write(
23693            dir.path().join("python.py"),
23694            r#"def fixture_nav_python_helper():
23695    return 1
23696
23697def fixture_nav_python_entry():
23698    return fixture_nav_python_helper()
23699"#,
23700        )
23701        .unwrap();
23702        std::fs::write(
23703            dir.path().join("typescript.ts"),
23704            r#"export function fixture_nav_typescript_entry(): number {
23705    return fixtureNavTsHelper();
23706}
23707
23708function fixtureNavTsHelper(): number {
23709    return 1;
23710}
23711"#,
23712        )
23713        .unwrap();
23714        std::fs::write(
23715            dir.path().join("javascript.js"),
23716            r#"function fixture_nav_javascript_entry() {
23717    return fixtureNavJsHelper();
23718}
23719
23720function fixtureNavJsHelper() {
23721    return 1;
23722}
23723"#,
23724        )
23725        .unwrap();
23726        std::fs::write(
23727            dir.path().join("kotlin.kt"),
23728            r#"fun fixture_nav_kotlin_entry(): Int {
23729    return fixtureNavKotlinHelper()
23730}
23731
23732fun fixtureNavKotlinHelper(): Int = 1
23733"#,
23734        )
23735        .unwrap();
23736        std::fs::write(
23737            dir.path().join("zig.zig"),
23738            r#"pub fn fixture_nav_zig_entry() i32 {
23739    return fixtureNavZigHelper();
23740}
23741
23742fn fixtureNavZigHelper() i32 {
23743    return 1;
23744}
23745"#,
23746        )
23747        .unwrap();
23748        std::fs::write(
23749            dir.path().join("gdscript.gd"),
23750            "extends Node\n\nfunc fixture_nav_gdscript_entry():\n\treturn fixture_nav_gdscript_helper()\n\nfunc fixture_nav_gdscript_helper():\n\treturn 1\n",
23751        )
23752        .unwrap();
23753        std::fs::write(
23754            dir.path().join("bash.sh"),
23755            r#"#!/usr/bin/env bash
23756fixture_nav_bash_entry() {
23757    fixture_nav_bash_helper
23758}
23759
23760fixture_nav_bash_helper() {
23761    echo ok
23762}
23763
23764alias fixture_nav_bash_alias='echo alias'
23765"#,
23766        )
23767        .unwrap();
23768        std::fs::write(
23769            dir.path().join("README.md"),
23770            r#"# Fixture Guide
23771
23772## Fixture Section
23773
23774- Fixture step
23775  - Nested fixture step
23776
23777```python
23778def fixture_nav_markdown_embedded():
23779    return 1
23780```
23781"#,
23782        )
23783        .unwrap();
23784
23785        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23786        db.apply_changes(dir.path()).unwrap();
23787        dir
23788    }
23789
23790    fn assert_cli_expand_command_parses(command: &str) {
23791        let args = shell_split(command)
23792            .into_iter()
23793            .map(str::to_string)
23794            .collect::<Vec<_>>();
23795        assert!(
23796            try_parse_cli(args).is_ok(),
23797            "expand command should parse as a tsift CLI command: {command}"
23798        );
23799    }
23800
23801    fn setup_multiplicity_project() -> tempfile::TempDir {
23802        let dir = tempfile::tempdir().unwrap();
23803        std::fs::write(
23804            dir.path().join("Cargo.toml"),
23805            r#"[workspace]
23806members = ["crates/core-lib", "crates/cli-app"]
23807"#,
23808        )
23809        .unwrap();
23810        std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23811        std::fs::write(
23812            dir.path().join("crates/core-lib/Cargo.toml"),
23813            r#"[package]
23814name = "core-lib"
23815
23816[lib]
23817name = "core_lib"
23818
23819[features]
23820default = []
23821"#,
23822        )
23823        .unwrap();
23824        std::fs::write(
23825            dir.path().join("crates/core-lib/src/lib.rs"),
23826            "pub fn run() {}\n",
23827        )
23828        .unwrap();
23829        std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23830        std::fs::write(
23831            dir.path().join("crates/cli-app/Cargo.toml"),
23832            r#"[package]
23833name = "cli-app"
23834
23835[[bin]]
23836name = "cli-app"
23837
23838[dependencies]
23839core-lib = { path = "../core-lib" }
23840"#,
23841        )
23842        .unwrap();
23843        std::fs::write(
23844            dir.path().join("crates/cli-app/src/main.rs"),
23845            "use core_lib::run;\nfn main() { run(); }\n",
23846        )
23847        .unwrap();
23848        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23849        db.apply_changes(dir.path()).unwrap();
23850
23851        let task_dir = dir.path().join("tasks/software");
23852        std::fs::create_dir_all(&task_dir).unwrap();
23853        std::fs::write(
23854            task_dir.join("tsift.md"),
23855            r#"---
23856agent_doc_session: tsift-multiplicity
23857agent_doc_format: template
23858---
23859
23860## Backlog
23861
23862<!-- agent:backlog -->
23863- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23864<!-- /agent:backlog -->
23865"#,
23866        )
23867        .unwrap();
23868        init_git_repo(dir.path());
23869        dir
23870    }
23871
23872    fn setup_dependency_dag_project() -> tempfile::TempDir {
23873        let dir = tempfile::tempdir().unwrap();
23874        std::fs::write(
23875            dir.path().join("main.rs"),
23876            "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23877        )
23878        .unwrap();
23879        std::fs::write(
23880            dir.path().join("Cargo.toml"),
23881            "[package]\nname = \"dag-fixture\"\n",
23882        )
23883        .unwrap();
23884        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23885        db.apply_changes(dir.path()).unwrap();
23886
23887        let task_dir = dir.path().join("tasks/software");
23888        std::fs::create_dir_all(&task_dir).unwrap();
23889        std::fs::write(
23890            task_dir.join("tsift.md"),
23891            r#"---
23892agent_doc_session: tsift-dag
23893agent_doc_format: template
23894---
23895
23896## Exchange
23897
23898<!-- agent:exchange patch=append -->
23899Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23900<!-- /agent:exchange -->
23901
23902## Backlog
23903
23904<!-- agent:backlog -->
23905- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23906- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23907- [ ] [#beta] Refactor shared_helper tests in main.rs.
23908- [ ] [#gamma] Follow-up review for graph navigation.
23909<!-- /agent:backlog -->
23910"#,
23911        )
23912        .unwrap();
23913        dir
23914    }
23915
23916    fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23917        let dir = setup_graph_index();
23918        let task_dir = dir.path().join("tasks/software");
23919        std::fs::create_dir_all(&task_dir).unwrap();
23920        std::fs::write(
23921            task_dir.join("tsift.md"),
23922            r#"---
23923agent_doc_session: tsift-dag-cycle
23924agent_doc_format: template
23925---
23926
23927## Backlog
23928
23929<!-- agent:backlog -->
23930- [ ] [#left] Left side depends on #right.
23931- [ ] [#right] Right side depends on #left.
23932<!-- /agent:backlog -->
23933"#,
23934        )
23935        .unwrap();
23936        dir
23937    }
23938
23939    fn seed_traversal_semantic_summaries(dir: &Path) {
23940        let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23941        summary_db
23942            .insert(&summarize::Summary {
23943                id: 0,
23944                symbol_name: "helper".to_string(),
23945                file_path: "main.rs".to_string(),
23946                content_hash: "hash-main".to_string(),
23947                summary: "helper builds graph navigation handles for traversal.".to_string(),
23948                entities: Some(vec![
23949                    summarize::Entity {
23950                        name: "helper".to_string(),
23951                        kind: "function".to_string(),
23952                        description: "Builds graph navigation handles.".to_string(),
23953                    },
23954                    summarize::Entity {
23955                        name: "TraversalGraph".to_string(),
23956                        kind: "type".to_string(),
23957                        description: "Carries GraphStore-backed traversal rows.".to_string(),
23958                    },
23959                ]),
23960                relationships: Some(vec![summarize::Relationship {
23961                    from: "helper".to_string(),
23962                    to: "TraversalGraph".to_string(),
23963                    kind: "uses".to_string(),
23964                }]),
23965                concept_labels: Some(vec![
23966                    "graph navigation".to_string(),
23967                    "semantic extraction".to_string(),
23968                ]),
23969                extracted_at: "1700000000".to_string(),
23970                model: "test-model".to_string(),
23971                tokens_input: Some(10),
23972                tokens_output: Some(5),
23973            })
23974            .unwrap();
23975    }
23976
23977    fn seed_tsift_memory_graph_db(dir: &Path) {
23978        let db = dir.join(".tsift").join("memory.db");
23979        let store = MemoryStore::open_or_create(&db).unwrap();
23980        let project = dir.to_string_lossy().to_string();
23981        let observation = MemoryEvent::new(
23982            MemoryEventKind::ImportedObservation,
23983            "claude-mem:observations:1",
23984            [
23985                "Graph memory adapter",
23986                "read-only projection",
23987                "graph-db should retrieve tsift memory observations",
23988                "Project memory is queried from .tsift/memory.db",
23989                "graph memory, tsift memory, semantic query",
23990            ]
23991            .join("\n\n"),
23992        )
23993        .with_session_id("claude-session-a")
23994        .with_observed_at_unix(1_700_000_000)
23995        .with_import("claude-mem", "observations:1")
23996        .with_metadata("project", project.clone())
23997        .with_metadata("observation_type", "fact")
23998        .with_metadata("prompt_number", "7")
23999        .with_metadata("discovery_tokens", "42")
24000        .with_metadata("content_hash", "hash-observation-1");
24001        store.insert_event(&observation).unwrap();
24002
24003        let summary = MemoryEvent::new(
24004            MemoryEventKind::ImportedSessionSummary,
24005            "claude-mem:session_summaries:2",
24006            [
24007                "Query old memory from graph-db",
24008                "Read-only tsift memory SQLite projection",
24009                "Semantic graph rows can point at existing memory",
24010                "Projected source and session nodes",
24011                "Keep capture ownership inside tsift-memory",
24012                "summary note",
24013            ]
24014            .join("\n\n"),
24015        )
24016        .with_session_id("claude-session-a")
24017        .with_observed_at_unix(1_700_000_010)
24018        .with_import("claude-mem", "session_summaries:2")
24019        .with_metadata("project", project)
24020        .with_metadata("prompt_number", "8")
24021        .with_metadata("discovery_tokens", "36");
24022        store.insert_event(&summary).unwrap();
24023
24024        let prompt = MemoryEvent::new(
24025            MemoryEventKind::ImportedUserPrompt,
24026            "claude-mem:user_prompts:3",
24027            "How can graph-db query tsift memory semantic history?",
24028        )
24029        .with_session_id("claude-session-a")
24030        .with_observed_at_unix(1_700_000_020)
24031        .with_import("claude-mem", "user_prompts:3")
24032        .with_metadata("prompt_number", "9");
24033        store.insert_event(&prompt).unwrap();
24034    }
24035
24036    #[test]
24037    fn graph_callers_query() {
24038        let dir = setup_graph_index();
24039        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24040        let callers = db.callers_of("helper").unwrap();
24041        assert_eq!(callers.len(), 1);
24042        assert_eq!(callers[0].caller_name, "main");
24043    }
24044
24045    #[test]
24046    fn graph_callees_query() {
24047        let dir = setup_graph_index();
24048        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24049        let callees = db.callees_of("main").unwrap();
24050        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
24051        assert!(names.contains(&"helper"));
24052        assert!(names.contains(&"new"));
24053    }
24054
24055    #[test]
24056    fn graph_no_callers_returns_empty() {
24057        let dir = setup_graph_index();
24058        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24059        let callers = db.callers_of("nonexistent").unwrap();
24060        assert!(callers.is_empty());
24061    }
24062
24063    #[test]
24064    fn graph_cmd_autoindexes_missing_index_by_default() {
24065        let dir = tempfile::tempdir().unwrap();
24066        std::fs::write(
24067            dir.path().join("main.rs"),
24068            "fn helper() {}\nfn main() { helper(); }\n",
24069        )
24070        .unwrap();
24071        let result = cmd_graph(
24072            "helper",
24073            dir.path(),
24074            true,
24075            false,
24076            None,
24077            20,
24078            false,
24079            true,
24080            false,
24081            false,
24082            false,
24083            false,
24084            false,
24085            TagpathSearchOpts::default(),
24086        );
24087
24088        assert!(result.is_ok());
24089        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
24090        let summary = db.compute_changes(dir.path()).unwrap();
24091        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
24092    }
24093
24094    #[test]
24095    fn traversal_graph_has_stable_typed_handles() {
24096        let dir = setup_traversal_project();
24097        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24098        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24099
24100        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
24101        let symbol = resolve_traversal_node(&graph, "helper").unwrap();
24102        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24103        let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
24104
24105        assert!(file.handle.starts_with("gfil-"));
24106        assert!(symbol.handle.starts_with("gsym-"));
24107        assert!(backlog.handle.starts_with("gbak-"));
24108        assert!(session.handle.starts_with("gses-"));
24109
24110        assert_eq!(
24111            symbol.handle,
24112            resolve_traversal_node(&graph_again, "helper")
24113                .unwrap()
24114                .handle
24115        );
24116        assert_eq!(
24117            backlog.handle,
24118            resolve_traversal_node(&graph_again, "#kgnv")
24119                .unwrap()
24120                .handle
24121        );
24122    }
24123
24124    #[test]
24125    fn traversal_graph_links_backlog_items_to_code_tokens() {
24126        let dir = setup_traversal_project();
24127        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24128        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24129        let helper = resolve_traversal_node(&graph, "helper").unwrap();
24130
24131        assert!(graph.edges.iter().any(|edge| {
24132            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24133        }));
24134    }
24135
24136    #[test]
24137    fn session_hinted_traversal_skips_global_call_edges() {
24138        let dir = setup_traversal_project();
24139        let session = dir.path().join("tasks/software/tsift.md");
24140        let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
24141        let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
24142        let helper = resolve_traversal_node(&bounded, "helper").unwrap();
24143
24144        assert!(bounded.edges.iter().any(|edge| {
24145            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24146        }));
24147        assert!(
24148            !bounded.edges.iter().any(|edge| edge.relation == "calls"),
24149            "session-hinted graph-db projections should not materialize unrelated global call edges"
24150        );
24151
24152        let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24153        assert!(
24154            full.edges.iter().any(|edge| edge.relation == "calls"),
24155            "root/full projections still carry the complete indexed call graph"
24156        );
24157    }
24158
24159    #[test]
24160    fn agent_doc_task_path_infers_matching_workspace_scope() {
24161        let dir = tempfile::tempdir().unwrap();
24162        std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
24163        std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
24164        std::fs::write(
24165            dir.path().join(".gitmodules"),
24166            "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
24167        )
24168        .unwrap();
24169        let task = dir.path().join("tasks/software/tsift.md");
24170        std::fs::write(&task, "# tsift\n").unwrap();
24171
24172        let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
24173        let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
24174        let cfg = config::Config::load(dir.path()).unwrap();
24175
24176        assert_eq!(targets.len(), 1);
24177        assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
24178        assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
24179        assert!(
24180            targets[0]
24181                .db_path
24182                .ends_with(".tsift/indexes/tsift/index.db")
24183        );
24184        assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
24185    }
24186
24187    #[test]
24188    fn cargo_package_scope_selector_indexes_package_db() {
24189        let dir = setup_multiplicity_project();
24190        let targets =
24191            resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
24192
24193        assert_eq!(targets.len(), 1);
24194        assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
24195        assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
24196        assert!(
24197            targets[0]
24198                .db_path
24199                .ends_with(".tsift/indexes/cargo/core-lib/index.db")
24200        );
24201
24202        cmd_index(
24203            dir.path(),
24204            false,
24205            false,
24206            false,
24207            false,
24208            true,
24209            false,
24210            Some("core_lib"),
24211            false,
24212            true,
24213            false,
24214            false,
24215            false,
24216            false,
24217        )
24218        .unwrap();
24219        assert!(targets[0].db_path.exists());
24220    }
24221
24222    #[test]
24223    fn source_read_symbols_build_cargo_package_index_on_demand() {
24224        // A workspace member that has never been queried has no per-package cargo
24225        // index yet. source-read must build it on demand and return AST symbol
24226        // refs rather than silently degrading to window-only output with an
24227        // "index refs unavailable" warning while `tsift status` reports fresh
24228        // (#cargoidxcov).
24229        let dir = setup_multiplicity_project();
24230        let cargo_index = dir.path().join(".tsift/indexes/cargo/core-lib/index.db");
24231        assert!(
24232            !cargo_index.exists(),
24233            "core-lib cargo index should not exist before the first source-read"
24234        );
24235
24236        let file_abs = dir.path().join("crates/core-lib/src/lib.rs");
24237        let source = std::fs::read(&file_abs).unwrap();
24238        let mut warnings = Vec::new();
24239        let symbols = load_source_symbols(
24240            dir.path(),
24241            &file_abs,
24242            "crates/core-lib/src/lib.rs",
24243            &source,
24244            None,
24245            1,
24246            usize::MAX,
24247            10,
24248            4096,
24249            &mut warnings,
24250        );
24251
24252        assert!(
24253            warnings.is_empty(),
24254            "source-read must build the index on demand instead of warning: {warnings:?}"
24255        );
24256        let symbol_names = symbols
24257            .iter()
24258            .map(|symbol| symbol.name.as_str())
24259            .collect::<Vec<_>>();
24260        assert!(
24261            symbol_names.contains(&"run"),
24262            "source-read should resolve `run` from the on-demand-built cargo index: {symbol_names:?}"
24263        );
24264        assert!(
24265            cargo_index.exists(),
24266            "source-read should have built the core-lib cargo index on demand"
24267        );
24268    }
24269
24270    #[test]
24271    fn path_inference_prefers_nested_cargo_package_without_submodule() {
24272        let dir = setup_multiplicity_project();
24273        let source = dir.path().join("crates/cli-app/src/main.rs");
24274        let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
24275
24276        assert_eq!(targets.len(), 1);
24277        assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
24278        assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
24279    }
24280
24281    #[test]
24282    fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
24283        let dir = setup_multiplicity_project();
24284        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24285        let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
24286        let core = resolve_traversal_node(&graph, "core-lib").unwrap();
24287        let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
24288        let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
24289
24290        assert_eq!(workspace.kind, "cargo_workspace");
24291        assert_eq!(core.kind, "cargo_package");
24292        assert_eq!(
24293            core.properties.get("features"),
24294            Some(&"default".to_string())
24295        );
24296        assert!(graph.edges.iter().any(|edge| {
24297            edge.from == workspace.handle
24298                && edge.to == core.handle
24299                && edge.relation == "contains_package"
24300        }));
24301        assert!(graph.edges.iter().any(|edge| {
24302            edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
24303        }));
24304        assert!(graph.edges.iter().any(|edge| {
24305            edge.from == cli.handle
24306                && edge.to == core.handle
24307                && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
24308        }));
24309    }
24310
24311    #[test]
24312    fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
24313        let dir = setup_multiplicity_project();
24314        let session = dir.path().join("tasks/software/tsift.md");
24315        let report =
24316            build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
24317                .unwrap();
24318
24319        assert!(report.per_target_fail_closed.is_empty());
24320        let candidate = report
24321            .candidates
24322            .iter()
24323            .find(|candidate| candidate.target == "corepkg")
24324            .unwrap();
24325        assert!(
24326            candidate
24327                .owned_files
24328                .iter()
24329                .any(|file| file == "crates/core-lib/Cargo.toml"),
24330            "{:?}",
24331            candidate.owned_files
24332        );
24333    }
24334
24335    #[test]
24336    fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
24337        let dir = setup_traversal_project();
24338        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24339        let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
24340        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24341
24342        assert_eq!(job.kind, "job_packet");
24343        assert!(job.handle.starts_with("gjob-"));
24344        assert!(graph.edges.iter().any(|edge| {
24345            edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
24346        }));
24347
24348        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24349        let jobs = store.nodes_by_kind("job_packet").unwrap();
24350        assert!(
24351            jobs.iter()
24352                .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24353            "expected queued job packet in graph store, got {jobs:?}"
24354        );
24355    }
24356
24357    #[test]
24358    fn traversal_graph_includes_routes_and_handler_edges() {
24359        let dir = tempfile::tempdir().unwrap();
24360        std::fs::write(
24361            dir.path().join("api.py"),
24362            r#"@router.get("/items")
24363def list_items():
24364    return []
24365"#,
24366        )
24367        .unwrap();
24368        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24369        db.apply_changes(dir.path()).unwrap();
24370
24371        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24372        let route = resolve_traversal_node(&graph, "/items").unwrap();
24373        let handler = resolve_traversal_node(&graph, "list_items").unwrap();
24374
24375        assert_eq!(route.kind, "route");
24376        assert!(graph.edges.iter().any(|edge| {
24377            edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
24378        }));
24379    }
24380
24381    #[test]
24382    fn traversal_graph_projects_rust_ast_navigation_edges() {
24383        let dir = tempfile::tempdir().unwrap();
24384        std::fs::write(
24385            dir.path().join("main.rs"),
24386            r#"mod api {
24387    pub fn helper() {}
24388    pub fn handler() { helper(); }
24389}
24390
24391fn main() { api::handler(); }
24392"#,
24393        )
24394        .unwrap();
24395        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24396        db.apply_changes(dir.path()).unwrap();
24397
24398        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24399        let api = resolve_ast_span_node(&graph, "api", "mod");
24400        let helper = resolve_ast_span_node(&graph, "helper", "function");
24401        let handler = resolve_ast_span_node(&graph, "handler", "function");
24402
24403        assert_eq!(helper.kind, "ast_span");
24404        assert!(helper.handle.starts_with("span-"));
24405        assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
24406        assert!(graph.edges.iter().any(|edge| {
24407            edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
24408        }));
24409        assert!(graph.edges.iter().any(|edge| {
24410            edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
24411        }));
24412        assert!(graph.edges.iter().any(|edge| {
24413            edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
24414        }));
24415        assert!(graph.edges.iter().any(|edge| {
24416            edge.from == helper.handle
24417                && edge.to == handler.handle
24418                && edge.relation == "next_sibling"
24419        }));
24420        assert!(graph.edges.iter().any(|edge| {
24421            edge.from == handler.handle
24422                && edge.to == helper.handle
24423                && edge.relation == "previous_sibling"
24424        }));
24425        assert!(graph.edges.iter().any(|edge| {
24426            edge.from == helper.handle
24427                && edge.to == api.handle
24428                && edge.relation == "enclosing_module"
24429        }));
24430        assert!(graph.edges.iter().any(|edge| {
24431            edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
24432        }));
24433
24434        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24435        let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
24436        assert!(
24437            ast_nodes.iter().any(|node| node.id == helper.handle
24438                && node.properties.get("symbol_kind") == Some(&"function".to_string())),
24439            "expected helper AST span in graph store, got {ast_nodes:?}"
24440        );
24441        assert!(
24442            store
24443                .outgoing_edges(&helper.handle, Some("parent"))
24444                .unwrap()
24445                .iter()
24446                .any(|edge| edge.to_id == api.handle),
24447            "expected persisted AST parent edge"
24448        );
24449    }
24450
24451    #[test]
24452    fn traversal_graph_projects_markdown_section_block_edges() {
24453        let dir = tempfile::tempdir().unwrap();
24454        std::fs::write(
24455            dir.path().join("README.md"),
24456            "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
24457        )
24458        .unwrap();
24459        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24460        db.apply_changes(dir.path()).unwrap();
24461
24462        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24463        let guide = resolve_ast_span_node(&graph, "Guide", "heading");
24464        let code = resolve_ast_span_node(&graph, "rust", "code_block");
24465        let embedded = resolve_ast_span_node(&graph, "demo", "function");
24466        let list_item = graph
24467            .nodes
24468            .values()
24469            .find(|node| {
24470                node.kind == "ast_span"
24471                    && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
24472                    && node.properties.get("section_handle") == Some(&guide.handle)
24473            })
24474            .expect("missing Markdown list item AST span");
24475
24476        assert_eq!(
24477            code.properties.get("markdown_block_kind"),
24478            Some(&"fenced_code_block".to_string())
24479        );
24480        assert_eq!(
24481            guide.properties.get("heading_level"),
24482            Some(&"1".to_string())
24483        );
24484        assert_eq!(
24485            embedded.properties.get("embedded"),
24486            Some(&"true".to_string())
24487        );
24488        assert_eq!(
24489            embedded.properties.get("language"),
24490            Some(&"rust".to_string())
24491        );
24492        assert_eq!(
24493            embedded.properties.get("markdown_block_handle"),
24494            Some(&code.handle)
24495        );
24496        assert!(graph.edges.iter().any(|edge| {
24497            edge.from == guide.handle
24498                && edge.to == code.handle
24499                && edge.relation == "contains_markdown_block"
24500        }));
24501        assert!(graph.edges.iter().any(|edge| {
24502            edge.from == code.handle
24503                && edge.to == guide.handle
24504                && edge.relation == "enclosing_section"
24505        }));
24506        assert!(graph.edges.iter().any(|edge| {
24507            edge.from == guide.handle
24508                && edge.to == list_item.handle
24509                && edge.relation == "contains_markdown_block"
24510        }));
24511        assert!(graph.edges.iter().any(|edge| {
24512            edge.from == code.handle
24513                && edge.to == embedded.handle
24514                && edge.relation == "contains_embedded_symbol"
24515        }));
24516        assert!(graph.edges.iter().any(|edge| {
24517            edge.from == embedded.handle
24518                && edge.to == code.handle
24519                && edge.relation == "embedded_in_fence"
24520        }));
24521        assert!(graph.edges.iter().any(|edge| {
24522            edge.from == guide.handle
24523                && edge.to == embedded.handle
24524                && edge.relation == "contains_embedded_code"
24525        }));
24526
24527        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24528        assert!(
24529            store
24530                .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
24531                .unwrap()
24532                .iter()
24533                .any(|edge| edge.to_id == code.handle),
24534            "expected persisted Markdown section/block edge"
24535        );
24536        assert!(
24537            store
24538                .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
24539                .unwrap()
24540                .iter()
24541                .any(|edge| edge.to_id == embedded.handle),
24542            "expected persisted Markdown fence/embedded symbol edge"
24543        );
24544    }
24545
24546    #[test]
24547    fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
24548        let dir = setup_multilingual_ast_navigation_project();
24549        let db =
24550            index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
24551        let symbols = db.all_symbols().unwrap();
24552        let expected_symbols = [
24553            ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
24554            (
24555                "python",
24556                "fixture_nav_python_entry",
24557                "function",
24558                "python.py",
24559            ),
24560            (
24561                "typescript",
24562                "fixture_nav_typescript_entry",
24563                "function",
24564                "typescript.ts",
24565            ),
24566            (
24567                "javascript",
24568                "fixture_nav_javascript_entry",
24569                "function",
24570                "javascript.js",
24571            ),
24572            (
24573                "kotlin",
24574                "fixture_nav_kotlin_entry",
24575                "function",
24576                "kotlin.kt",
24577            ),
24578            ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
24579            (
24580                "gdscript",
24581                "fixture_nav_gdscript_entry",
24582                "function",
24583                "gdscript.gd",
24584            ),
24585            ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
24586            ("markdown", "Fixture Section", "heading", "README.md"),
24587            ("markdown", "Fixture step", "list_item", "README.md"),
24588            ("markdown", "python", "code_block", "README.md"),
24589        ];
24590
24591        for (language, name, kind, file) in expected_symbols {
24592            let symbol = symbols
24593                .iter()
24594                .find(|symbol| {
24595                    symbol.language == language
24596                        && symbol.name == name
24597                        && symbol.kind == kind
24598                        && symbol.file.ends_with(file)
24599                })
24600                .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
24601            assert!(
24602                symbol.start_byte.is_some() && symbol.end_byte.is_some(),
24603                "{language} {name} should carry AST byte spans"
24604            );
24605        }
24606
24607        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24608        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24609        let expected_ast_nodes = [
24610            ("fixture_nav_rust_entry", "function", "rust"),
24611            ("fixture_nav_python_entry", "function", "python"),
24612            ("fixture_nav_typescript_entry", "function", "typescript"),
24613            ("fixture_nav_javascript_entry", "function", "javascript"),
24614            ("fixture_nav_kotlin_entry", "function", "kotlin"),
24615            ("fixture_nav_zig_entry", "function", "zig"),
24616            ("fixture_nav_gdscript_entry", "function", "gdscript"),
24617            ("fixture_nav_bash_entry", "function", "bash"),
24618            ("Fixture Section", "heading", "markdown"),
24619            ("Fixture step", "list_item", "markdown"),
24620            ("python", "code_block", "markdown"),
24621            ("fixture_nav_markdown_embedded", "function", "python"),
24622        ];
24623
24624        for (name, kind, language) in expected_ast_nodes {
24625            let node = resolve_ast_span_node(&graph, name, kind);
24626            let repeated = resolve_ast_span_node(&graph_again, name, kind);
24627            assert!(
24628                node.handle.starts_with("span-"),
24629                "{name} handle: {}",
24630                node.handle
24631            );
24632            assert_eq!(
24633                node.handle, repeated.handle,
24634                "{language} {name} handle drifted"
24635            );
24636            assert_eq!(
24637                node.properties.get("language"),
24638                Some(&language.to_string()),
24639                "{name} should keep its language label"
24640            );
24641        }
24642
24643        let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
24644        let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
24645        let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
24646        assert!(graph.edges.iter().any(|edge| {
24647            edge.from == markdown_section.handle
24648                && edge.to == markdown_code.handle
24649                && edge.relation == "contains_markdown_block"
24650        }));
24651        assert!(graph.edges.iter().any(|edge| {
24652            edge.from == markdown_code.handle
24653                && edge.to == embedded.handle
24654                && edge.relation == "contains_embedded_symbol"
24655        }));
24656        assert!(
24657            graph.nodes.len() <= 80,
24658            "multilingual AST fixture should stay bounded, got {} nodes",
24659            graph.nodes.len()
24660        );
24661        assert!(
24662            graph.edges.len() <= 180,
24663            "multilingual AST fixture should stay bounded, got {} edges",
24664            graph.edges.len()
24665        );
24666
24667        let response = empty_search_response(dir.path(), "lexical");
24668        let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
24669        let report = build_relative_search_budget_report(
24670            "fixture_nav_python_entry",
24671            "lexical",
24672            dir.path(),
24673            &response,
24674            &symbol_hits,
24675            ResponseBudget::new(Some(8), Some(120)),
24676            &SearchFacetFilters::default(),
24677        );
24678        let report_again = build_relative_search_budget_report(
24679            "fixture_nav_python_entry",
24680            "lexical",
24681            dir.path(),
24682            &response,
24683            &symbol_hits,
24684            ResponseBudget::new(Some(8), Some(120)),
24685            &SearchFacetFilters::default(),
24686        );
24687
24688        let top = report
24689            .ranked
24690            .first()
24691            .expect("ranked preview should not be empty");
24692        assert_eq!(top.source, "symbol_span");
24693        assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
24694        assert!(top.handle.starts_with("srnk-"));
24695        assert_eq!(top.handle, report_again.ranked[0].handle);
24696        assert!(
24697            top.reasons.iter().any(|reason| reason == "ast_span"),
24698            "expected AST span ranking reason, got {:?}",
24699            top.reasons
24700        );
24701        assert!(report.ranked.len() <= 8);
24702        assert!(report.symbols.len() <= 8);
24703
24704        let symbol = report
24705            .symbols
24706            .iter()
24707            .find(|symbol| symbol.name == "fixture_nav_python_entry")
24708            .expect("missing search preview symbol");
24709        assert_cli_expand_command_parses(&symbol.expand);
24710        let ast = symbol
24711            .ast
24712            .as_ref()
24713            .expect("search symbol should expose AST");
24714        assert_cli_expand_command_parses(&ast.expand.source_window);
24715        assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
24716        assert_cli_expand_command_parses(&ast.expand.symbol_read);
24717
24718        let markdown_hits = db.symbol_search("python", 20).unwrap();
24719        let markdown_report = build_relative_search_budget_report(
24720            "python",
24721            "lexical",
24722            dir.path(),
24723            &response,
24724            &markdown_hits,
24725            ResponseBudget::new(Some(8), Some(120)),
24726            &SearchFacetFilters::default(),
24727        );
24728        let markdown_symbol = markdown_report
24729            .symbols
24730            .iter()
24731            .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
24732            .expect("missing Markdown code-block symbol");
24733        let markdown_ast = markdown_symbol
24734            .ast
24735            .as_ref()
24736            .expect("Markdown code block should expose AST");
24737        assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
24738        assert_eq!(
24739            markdown_ast
24740                .span
24741                .markdown
24742                .as_ref()
24743                .unwrap()
24744                .embedded_symbols[0]
24745                .name,
24746            "fixture_nav_markdown_embedded"
24747        );
24748    }
24749
24750    #[test]
24751    fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
24752        let edges = vec![
24753            TraversalEdge {
24754                from: "origin".to_string(),
24755                to: "aaa_low".to_string(),
24756                relation: "unknown".to_string(),
24757                label: None,
24758                weight: 1,
24759            },
24760            TraversalEdge {
24761                from: "origin".to_string(),
24762                to: "zzz_high".to_string(),
24763                relation: "mentions".to_string(),
24764                label: None,
24765                weight: 1,
24766            },
24767        ];
24768
24769        let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
24770
24771        assert!(handles.contains("origin"));
24772        assert!(handles.contains("zzz_high"), "{handles:?}");
24773        assert!(!handles.contains("aaa_low"), "{handles:?}");
24774    }
24775
24776    #[test]
24777    fn traversal_materializes_provider_neutral_sqlite_graph() {
24778        let dir = setup_traversal_project();
24779        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24780        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24781
24782        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24783        let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
24784        assert!(
24785            backlog_nodes.iter().any(|node| node.id == backlog.handle
24786                && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24787            "expected materialized backlog node, got {backlog_nodes:?}"
24788        );
24789        assert!(
24790            store
24791                .all_nodes()
24792                .unwrap()
24793                .iter()
24794                .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
24795                    && node.properties.get("projection_version")
24796                        == Some(&GRAPH_PROJECTION_VERSION.to_string())),
24797            "expected projection metadata node"
24798        );
24799        let source_handles = store.nodes_by_kind("source_handle").unwrap();
24800        assert!(
24801            source_handles
24802                .iter()
24803                .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
24804            "expected bounded source_handle rows, got {source_handles:?}"
24805        );
24806        let worker_context = store.nodes_by_kind("worker_context").unwrap();
24807        assert!(
24808            worker_context
24809                .iter()
24810                .any(|node| node.properties.get("target")
24811                    == Some(&"tasks/software/tsift.md".to_string())),
24812            "expected bounded worker_context rows, got {worker_context:?}"
24813        );
24814        let worker_results = store.nodes_by_kind("worker_result").unwrap();
24815        assert!(
24816            worker_results.iter().any(|node| {
24817                node.properties.get("ref_id") == Some(&"kgnv".to_string())
24818                    && node.properties.get("status") == Some(&"completed".to_string())
24819                    && node.properties.get("touched_files") == Some(&"main.rs".to_string())
24820                    && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
24821            }),
24822            "expected worker_result rows, got {worker_results:?}"
24823        );
24824    }
24825
24826    #[test]
24827    fn traversal_projection_materializes_cached_semantic_rows() {
24828        let dir = setup_traversal_project();
24829        seed_traversal_semantic_summaries(dir.path());
24830        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24831        let helper = resolve_traversal_node(&graph, "helper").unwrap();
24832        let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
24833        let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
24834
24835        assert_eq!(concept.kind, "semantic_concept");
24836        assert_eq!(entity.kind, "semantic_entity");
24837        assert!(concept.handle.starts_with("gcon-"));
24838        assert!(entity.handle.starts_with("gent-"));
24839
24840        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24841        assert!(
24842            store
24843                .nodes_by_kind("semantic_concept")
24844                .unwrap()
24845                .iter()
24846                .any(|node| node.label == "semantic extraction"
24847                    && node.properties.contains_key("embedding")),
24848            "expected persisted concept embeddings"
24849        );
24850        assert!(
24851            store
24852                .outgoing_edges(&helper.handle, Some("mentions_concept"))
24853                .unwrap()
24854                .iter()
24855                .any(|edge| edge.to_id == concept.handle),
24856            "expected helper symbol to link to cached summary concept"
24857        );
24858        assert!(
24859            store
24860                .outgoing_edges(
24861                    &semantic_entity_handle("helper", "function"),
24862                    Some("semantic_relation")
24863                )
24864                .unwrap()
24865                .iter()
24866                .any(|edge| edge.to_id == entity.handle
24867                    && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24868            "expected LLM relationship rows projected into GraphStore"
24869        );
24870    }
24871
24872    #[test]
24873    fn traversal_projection_materializes_tsift_memory_rows() {
24874        let dir = setup_traversal_project();
24875        seed_tsift_memory_graph_db(dir.path());
24876        let memory_db = dir.path().join(".tsift").join("memory.db");
24877        let store = MemoryStore::open_or_create(&memory_db).unwrap();
24878        for summary in ["first closeout", "second closeout"] {
24879            let event = MemoryEvent::new(
24880                MemoryEventKind::ResponseSummary,
24881                "tasks/software/tsift.md",
24882                summary,
24883            )
24884            .with_session_id("tasks/software/tsift.md")
24885            .with_observed_at_unix(1_700_000_100);
24886            store.insert_event(&event).unwrap();
24887        }
24888        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24889        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24890
24891        let native_sources = store
24892            .nodes_by_kind("source_handle")
24893            .unwrap()
24894            .into_iter()
24895            .filter(|node| {
24896                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24897                    && node.properties.get("source_ref")
24898                        == Some(&"tasks/software/tsift.md".to_string())
24899            })
24900            .collect::<Vec<_>>();
24901        assert_eq!(
24902            native_sources.len(),
24903            2,
24904            "same-source native memory events must get distinct source handles"
24905        );
24906
24907        let source = store
24908            .nodes_by_kind("source_handle")
24909            .unwrap()
24910            .into_iter()
24911            .find(|node| {
24912                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24913            })
24914            .expect("expected tsift-memory source handle");
24915        let session = store
24916            .nodes_by_kind("memory_session")
24917            .unwrap()
24918            .into_iter()
24919            .find(|node| {
24920                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24921                    && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24922            })
24923            .expect("expected tsift-memory session node");
24924        let event = store
24925            .nodes_by_kind("memory_event")
24926            .unwrap()
24927            .into_iter()
24928            .find(|node| {
24929                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24930                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24931                    && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24932            })
24933            .expect("expected tsift-memory event node");
24934        let concept = store
24935            .nodes_by_kind("semantic_concept")
24936            .unwrap()
24937            .into_iter()
24938            .find(|node| {
24939                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24940                    && node.label.contains("Graph memory adapter")
24941                    && node.properties.contains_key("embedding")
24942            })
24943            .expect("expected tsift-memory semantic concept");
24944
24945        assert!(
24946            store
24947                .outgoing_edges(&session.id, Some("records_memory_source"))
24948                .unwrap()
24949                .iter()
24950                .any(|edge| edge.to_id == source.id),
24951            "expected session to link to source handle"
24952        );
24953        assert!(
24954            store
24955                .outgoing_edges(&session.id, Some("records_memory_event"))
24956                .unwrap()
24957                .iter()
24958                .any(|edge| edge.to_id == event.id),
24959            "expected session to link to memory event"
24960        );
24961        assert!(
24962            store
24963                .outgoing_edges(&event.id, Some("projects_source"))
24964                .unwrap()
24965                .iter()
24966                .any(|edge| edge.to_id == source.id),
24967            "expected memory event to project source handle"
24968        );
24969        assert!(
24970            store
24971                .outgoing_edges(&source.id, Some("mentions_concept"))
24972                .unwrap()
24973                .iter()
24974                .any(|edge| edge.to_id == concept.id),
24975            "expected source handle to seed semantic concept"
24976        );
24977
24978        let related = semantic_related_report_from_store(
24979            dir.path(),
24980            None,
24981            "tsift memory graph adapter",
24982            5,
24983            SemanticRelatedKind::Concept,
24984            &store,
24985        )
24986        .unwrap();
24987        assert!(
24988            related
24989                .items
24990                .iter()
24991                .any(|item| item.handle == concept.id && item.score > 0.0),
24992            "expected semantic query to retrieve tsift-memory concept, got {:?}",
24993            related.items
24994        );
24995
24996        let graph_related = graph_db_report_from_store(
24997            dir.path(),
24998            None,
24999            "sqlite",
25000            GraphDbQuery::Related {
25001                query: "tsift memory graph adapter".to_string(),
25002                kind: SemanticRelatedKind::Concept,
25003                depth: 1,
25004                seed_limit: 5,
25005                limit: 20,
25006            },
25007            &store,
25008            sqlite_graph_freshness(&store, "root").unwrap(),
25009            Vec::new(),
25010        )
25011        .unwrap();
25012        assert_eq!(
25013            graph_related
25014                .readiness
25015                .as_ref()
25016                .map(|readiness| readiness.status.as_str()),
25017            Some("ready"),
25018            "tsift-memory semantic rows should satisfy graph-db related readiness"
25019        );
25020        assert!(
25021            graph_related.nodes.iter().any(|node| {
25022                node.kind == "semantic_concept"
25023                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
25024            }),
25025            "expected related graph output to include tsift-memory semantic rows"
25026        );
25027    }
25028
25029    #[test]
25030    fn semantic_related_query_uses_persisted_graph_embeddings() {
25031        let dir = setup_traversal_project();
25032        seed_traversal_semantic_summaries(dir.path());
25033        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25034        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25035        let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
25036            .unwrap()
25037            .query_row(
25038                "SELECT COUNT(*) FROM graph_node_semantic_vectors",
25039                [],
25040                |row| row_usize(row, 0),
25041            )
25042            .unwrap();
25043        assert!(semantic_vector_rows > 0);
25044
25045        let report = semantic_related_report_from_store(
25046            dir.path(),
25047            None,
25048            "graph navigation",
25049            5,
25050            SemanticRelatedKind::Concept,
25051            &store,
25052        )
25053        .unwrap();
25054
25055        assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
25056        assert!(
25057            report
25058                .items
25059                .iter()
25060                .any(|item| item.label == "graph navigation"
25061                    && item.kind == "semantic_concept"
25062                    && item.score > 0.9),
25063            "expected nearest concept match from graph embeddings, got {:?}",
25064            report.items
25065        );
25066    }
25067
25068    #[test]
25069    fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
25070        let dir = setup_traversal_project();
25071        seed_traversal_semantic_summaries(dir.path());
25072        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25073        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25074
25075        let report = graph_db_report_from_store(
25076            dir.path(),
25077            None,
25078            "sqlite",
25079            GraphDbQuery::Related {
25080                query: "graph navigation".to_string(),
25081                kind: SemanticRelatedKind::All,
25082                depth: 1,
25083                seed_limit: 2,
25084                limit: 20,
25085            },
25086            &store,
25087            sqlite_graph_freshness(&store, "root").unwrap(),
25088            Vec::new(),
25089        )
25090        .unwrap();
25091
25092        let knowledge = report.knowledge_retrieval.as_ref().unwrap();
25093        assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
25094        assert_eq!(knowledge.seed_kind, "all");
25095        assert_eq!(knowledge.depth, 1);
25096        assert_eq!(
25097            report
25098                .readiness
25099                .as_ref()
25100                .map(|readiness| readiness.status.as_str()),
25101            Some("ready")
25102        );
25103        assert!(
25104            knowledge
25105                .diagnostics
25106                .iter()
25107                .any(|diagnostic| diagnostic.contains("incident"))
25108        );
25109        assert!(
25110            report
25111                .semantic_related
25112                .iter()
25113                .any(|item| item.label == "graph navigation"
25114                    && item.kind == "semantic_concept"
25115                    && item.score > 0.9),
25116            "expected natural-language query to seed the graph navigation concept, got {:?}",
25117            report.semantic_related
25118        );
25119        assert!(
25120            report
25121                .nodes
25122                .iter()
25123                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
25124        );
25125        assert!(
25126            report
25127                .nodes
25128                .iter()
25129                .any(|node| node.kind == "symbol" && node.label == "helper"),
25130            "incident expansion from semantic seed should recover source symbols, got {:?}",
25131            report
25132                .nodes
25133                .iter()
25134                .map(|node| (&node.kind, &node.label))
25135                .collect::<Vec<_>>()
25136        );
25137        assert!(
25138            report
25139                .edges
25140                .iter()
25141                .any(|edge| edge.kind == "mentions_concept")
25142        );
25143        assert!(
25144            report.output_budget.as_ref().is_some_and(|budget| budget
25145                .diagnostics
25146                .iter()
25147                .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
25148            "expected related output budget diagnostics, got {:?}",
25149            report.output_budget
25150        );
25151    }
25152
25153    #[test]
25154    fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
25155        let dir = setup_graph_index();
25156        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25157        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25158
25159        let report = graph_db_report_from_store(
25160            dir.path(),
25161            None,
25162            "sqlite",
25163            GraphDbQuery::Related {
25164                query: "graph navigation".to_string(),
25165                kind: SemanticRelatedKind::All,
25166                depth: 1,
25167                seed_limit: 2,
25168                limit: 20,
25169            },
25170            &store,
25171            sqlite_graph_freshness(&store, "root").unwrap(),
25172            Vec::new(),
25173        )
25174        .unwrap();
25175
25176        let readiness = report.readiness.as_ref().unwrap();
25177        assert_eq!(readiness.status, "blocked");
25178        assert_eq!(readiness.reason, "summary_cache_empty");
25179        assert!(readiness.fail_closed);
25180        assert_eq!(
25181            readiness.next_commands,
25182            vec![
25183                "tsift summarize --extract .".to_string(),
25184                graph_db_refresh_command(dir.path(), None)
25185            ]
25186        );
25187        assert!(
25188            report
25189                .knowledge_retrieval
25190                .as_ref()
25191                .unwrap()
25192                .diagnostics
25193                .iter()
25194                .any(|diagnostic| diagnostic.contains("summary cache empty")
25195                    && diagnostic.contains("graph-db materialized code/session rows")),
25196            "expected related diagnostics to carry readiness gate, got {:?}",
25197            report.knowledge_retrieval.as_ref().unwrap().diagnostics
25198        );
25199    }
25200
25201    #[test]
25202    fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
25203        let mut nodes = vec![
25204            SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
25205            SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
25206        ];
25207        let mut edges = vec![SubstrateGraphEdge::new(
25208            "zzz_high",
25209            "seed",
25210            "mentions_concept",
25211        )];
25212        for idx in 0..24 {
25213            let id = format!("aaa_low_{idx:02}");
25214            nodes.push(SubstrateGraphNode::new(
25215                id.clone(),
25216                "note",
25217                format!("low {idx}"),
25218            ));
25219            edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
25220        }
25221        let mut store = SqliteGraphStore::in_memory().unwrap();
25222        store
25223            .replace_projection(&GraphProjection { nodes, edges })
25224            .unwrap();
25225
25226        let subgraph =
25227            graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
25228
25229        assert_eq!(subgraph.nodes.len(), 3);
25230        assert_eq!(subgraph.nodes[0].id, "seed");
25231        assert_eq!(
25232            subgraph.nodes[1].id, "zzz_high",
25233            "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
25234            subgraph.nodes
25235        );
25236        assert!(subgraph.truncated);
25237        assert!(
25238            subgraph
25239                .diagnostics
25240                .iter()
25241                .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
25242            "{:?}",
25243            subgraph.diagnostics
25244        );
25245        assert!(
25246            subgraph
25247                .diagnostics
25248                .iter()
25249                .any(|diagnostic| diagnostic.contains("skipped")),
25250            "{:?}",
25251            subgraph.diagnostics
25252        );
25253    }
25254
25255    #[test]
25256    fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
25257        let dir = setup_traversal_project();
25258        seed_traversal_semantic_summaries(dir.path());
25259        init_git_repo(dir.path());
25260        let session = dir.path().join("tasks/software/tsift.md");
25261        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25262        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25263        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25264        let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25265            root: dir.path(),
25266            scope: None,
25267            backend: "sqlite",
25268            target: "kgnv",
25269            preferred_path: None,
25270            depth: 4,
25271            limit: 8,
25272            cursor: None,
25273            store: &store,
25274            freshness,
25275            warnings: Vec::new(),
25276        })
25277        .unwrap();
25278        assert!(
25279            evidence
25280                .semantic_related
25281                .iter()
25282                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
25283            "expected semantic evidence rows, got {:?}",
25284            evidence
25285                .semantic_related
25286                .iter()
25287                .map(|node| (&node.kind, &node.label))
25288                .collect::<Vec<_>>()
25289        );
25290        assert!(
25291            evidence
25292                .output_budget
25293                .as_ref()
25294                .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
25295                    diagnostic.contains("semantic_match")
25296                        && diagnostic.contains("source_handle_coverage")
25297                })),
25298            "expected evidence output budget diagnostics, got {:?}",
25299            evidence.output_budget
25300        );
25301
25302        let cached_diff = diff_digest::compute(
25303            dir.path(),
25304            diff_digest::DiffDigestOptions {
25305                cached: true,
25306                revision: None,
25307                max_parsed_files: None,
25308            },
25309        )
25310        .unwrap();
25311        let impact_report = impact::compute(
25312            dir.path(),
25313            impact::ImpactOptions {
25314                cached: true,
25315                revision: None,
25316                scope: None,
25317                limit: 10,
25318            },
25319        )
25320        .unwrap();
25321        let graph_nodes = store.all_nodes().unwrap();
25322        let graph_index = conflict_matrix_graph_index(&graph_nodes);
25323        let semantic_candidate = conflict_matrix_candidate_from_evidence(
25324            dir.path(),
25325            &evidence,
25326            &graph_index,
25327            &cached_diff,
25328            &impact_report,
25329        );
25330        assert!(semantic_candidate.semantic_dispatch_score > 0);
25331        assert!(
25332            semantic_candidate
25333                .semantic_dispatch_reasons
25334                .iter()
25335                .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
25336            "expected semantic ranking explanations, got {:?}",
25337            semantic_candidate.semantic_dispatch_reasons
25338        );
25339        assert!(
25340            semantic_candidate
25341                .semantic_related
25342                .iter()
25343                .any(|item| item.label == "graph navigation")
25344        );
25345
25346        let mut plain_candidate = semantic_candidate.clone();
25347        plain_candidate.target = "plain".to_string();
25348        plain_candidate.semantic_related.clear();
25349        plain_candidate.semantic_dispatch_score = 0;
25350        plain_candidate.semantic_dispatch_reasons.clear();
25351        let mut ranked = [plain_candidate, semantic_candidate];
25352        ranked.sort_by(|left, right| {
25353            left.risk
25354                .cmp(&right.risk)
25355                .then_with(|| left.risk_score.cmp(&right.risk_score))
25356                .then_with(|| {
25357                    right
25358                        .semantic_dispatch_score
25359                        .cmp(&left.semantic_dispatch_score)
25360                })
25361                .then_with(|| left.target.cmp(&right.target))
25362        });
25363        assert_eq!(ranked[0].target, "kgnv");
25364    }
25365
25366    #[test]
25367    fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
25368        let dir = setup_dependency_dag_project();
25369        let session = dir.path().join("tasks/software/tsift.md");
25370        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25371
25372        assert_eq!(report.contract_version, "dependency-dag-v1");
25373        assert_eq!(
25374            report.targets,
25375            vec![
25376                "prep".to_string(),
25377                "alpha".to_string(),
25378                "beta".to_string(),
25379                "gamma".to_string()
25380            ]
25381        );
25382        assert!(report.edges.iter().any(|edge| {
25383            edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
25384        }));
25385        assert!(report.edges.iter().any(|edge| {
25386            edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
25387        }));
25388        assert!(report.edges.iter().any(|edge| {
25389            edge.from == "alpha"
25390                && edge.to == "beta"
25391                && edge.kind == "shared_resource"
25392                && edge.shared_files.contains(&"main.rs".to_string())
25393                && edge.shared_symbols.contains(&"shared_helper".to_string())
25394        }));
25395        assert!(
25396            !report.cycle_diagnostics.has_cycles,
25397            "{:?}",
25398            report.cycle_diagnostics
25399        );
25400        assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
25401        assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
25402        assert!(
25403            report.replay_commands[0].contains("dependency-dag"),
25404            "{:?}",
25405            report.replay_commands
25406        );
25407
25408        cmd_dependency_dag(
25409            &session,
25410            None,
25411            &["alpha".to_string(), "beta".to_string()],
25412            4,
25413            12,
25414            OutputFormat {
25415                json_output: true,
25416                compact: false,
25417                pretty: false,
25418                terse: false,
25419                ultra_terse: false,
25420                schema: false,
25421                envelope: false,
25422            },
25423        )
25424        .unwrap();
25425    }
25426
25427    #[test]
25428    fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
25429        let dir = setup_dependency_dag_cycle_project();
25430        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25431
25432        assert!(report.cycle_diagnostics.has_cycles);
25433        assert_eq!(
25434            report.cycle_diagnostics.blocked_nodes,
25435            vec!["left".to_string(), "right".to_string()]
25436        );
25437        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25438            edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
25439        }));
25440        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25441            edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
25442        }));
25443    }
25444
25445    #[test]
25446    fn traversal_projection_queries_match_sqlite_and_convex_stores() {
25447        let dir = setup_traversal_project();
25448        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
25449        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
25450
25451        let mut sqlite = SqliteGraphStore::in_memory().unwrap();
25452        sqlite.replace_projection(&projection).unwrap();
25453        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25454        projection.upsert_into(&convex).unwrap();
25455
25456        let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
25457        let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
25458        assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
25459        assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
25460
25461        let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
25462        let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
25463        assert!(convex_graph.edges.iter().any(|edge| {
25464            edge.from == sqlite_backlog.handle
25465                && edge.to == convex_helper.handle
25466                && edge.relation == "mentions"
25467        }));
25468    }
25469
25470    #[test]
25471    fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
25472        let dir = setup_traversal_project();
25473        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25474        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25475        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25476        assert_eq!(freshness.status, "current");
25477
25478        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25479        let report = graph_db_report_from_store(
25480            dir.path(),
25481            None,
25482            "sqlite",
25483            GraphDbQuery::Neighborhood {
25484                id: backlog.handle.clone(),
25485                depth: 1,
25486                edge_kind: Some("mentions".to_string()),
25487                cursor: None,
25488                limit: None,
25489                property_filters: Vec::new(),
25490            },
25491            &store,
25492            freshness,
25493            Vec::new(),
25494        )
25495        .unwrap();
25496        assert!(
25497            report
25498                .edges
25499                .iter()
25500                .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
25501            "expected backlog mention edge, got {:?}",
25502            report.edges
25503        );
25504        assert!(
25505            report.ranked_neighbors.iter().any(|neighbor| {
25506                neighbor.depth == Some(1)
25507                    && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
25508                    && neighbor.node_id != backlog.handle
25509                    && neighbor.handle_coverage_pct >= 95.0
25510                    && neighbor.duplicate_name_precision >= 0.99
25511            }),
25512            "expected ranked neighborhood neighbors with quality scores, got {:?}",
25513            report.ranked_neighbors
25514        );
25515        assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
25516        let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
25517        assert!(!ranking_gate.ranked_output_default);
25518        assert_eq!(ranking_gate.default_order, "stable_node_id");
25519        assert!(
25520            ranking_gate
25521                .diagnostics
25522                .iter()
25523                .any(|diagnostic| diagnostic.contains("score-capped")),
25524            "{ranking_gate:?}"
25525        );
25526        assert!(
25527            ranking_gate
25528                .required_metrics
25529                .iter()
25530                .any(|metric| metric == "handle_coverage_pct")
25531        );
25532        assert!(
25533            ranking_gate
25534                .required_metrics
25535                .iter()
25536                .any(|metric| metric == "duplicate_name_precision")
25537        );
25538        assert!(
25539            report
25540                .page
25541                .as_ref()
25542                .unwrap()
25543                .diagnostics
25544                .iter()
25545                .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
25546            "expected SQLite neighborhood query plan diagnostics, got {:?}",
25547            report.page.as_ref().unwrap().diagnostics
25548        );
25549        let edges_report = graph_db_report_from_store(
25550            dir.path(),
25551            None,
25552            "sqlite",
25553            GraphDbQuery::Edges {
25554                edge_kind: Some("mentions".to_string()),
25555                cursor: None,
25556                limit: Some(2),
25557                property_filters: Vec::new(),
25558            },
25559            &store,
25560            sqlite_graph_freshness(&store, "root").unwrap(),
25561            Vec::new(),
25562        )
25563        .unwrap();
25564        let edge_id = edges_report
25565            .edges
25566            .first()
25567            .map(|edge| edge.id.clone())
25568            .expect("expected at least one paged mentions edge");
25569        assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
25570        assert_eq!(
25571            edges_report.page.as_ref().unwrap().returned_edges,
25572            edges_report.edges.len()
25573        );
25574
25575        let edge_report = graph_db_report_from_store(
25576            dir.path(),
25577            None,
25578            "sqlite",
25579            GraphDbQuery::Edge {
25580                id: edge_id.clone(),
25581            },
25582            &store,
25583            sqlite_graph_freshness(&store, "root").unwrap(),
25584            Vec::new(),
25585        )
25586        .unwrap();
25587        assert_eq!(
25588            edge_report
25589                .edge
25590                .as_ref()
25591                .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
25592            Some(edge_id.clone())
25593        );
25594
25595        let incident_report = graph_db_report_from_store(
25596            dir.path(),
25597            None,
25598            "sqlite",
25599            GraphDbQuery::Incident {
25600                id: backlog.handle.clone(),
25601                edge_kind: Some("mentions".to_string()),
25602                cursor: None,
25603                limit: Some(1),
25604                property_filters: Vec::new(),
25605            },
25606            &store,
25607            sqlite_graph_freshness(&store, "root").unwrap(),
25608            Vec::new(),
25609        )
25610        .unwrap();
25611        assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
25612        assert!(
25613            incident_report
25614                .edges
25615                .iter()
25616                .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
25617            "{:?}",
25618            incident_report.edges
25619        );
25620
25621        let schema_report = graph_db_report_from_store(
25622            dir.path(),
25623            None,
25624            "sqlite",
25625            GraphDbQuery::Schema,
25626            &store,
25627            sqlite_graph_freshness(&store, "root").unwrap(),
25628            Vec::new(),
25629        )
25630        .unwrap();
25631        assert!(
25632            schema_report
25633                .schema
25634                .unwrap()
25635                .operations
25636                .iter()
25637                .any(|operation| operation.command.starts_with("neighborhood"))
25638        );
25639    }
25640
25641    #[test]
25642    fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
25643        let mut nodes = vec![SubstrateGraphNode::new(
25644            "origin",
25645            "backlog",
25646            "#budgeted-neighborhood",
25647        )];
25648        let mut edges = Vec::new();
25649        for idx in 0..32 {
25650            let id = format!("src-{idx:02}");
25651            nodes.push(
25652                SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
25653                    .with_property("source_ref", format!("fixture:{idx}"))
25654                    .with_property("detail", "x".repeat(600)),
25655            );
25656            edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
25657        }
25658        let store = SqliteGraphStore::in_memory().unwrap();
25659        GraphProjection { nodes, edges }
25660            .upsert_into(&store)
25661            .unwrap();
25662
25663        let report = graph_db_report_from_store(
25664            Path::new("."),
25665            None,
25666            "fixture",
25667            GraphDbQuery::Neighborhood {
25668                id: "origin".to_string(),
25669                depth: 1,
25670                edge_kind: None,
25671                cursor: None,
25672                limit: None,
25673                property_filters: Vec::new(),
25674            },
25675            &store,
25676            current_graph_db_freshness(),
25677            Vec::new(),
25678        )
25679        .unwrap();
25680        let budget = report.output_budget.as_ref().unwrap();
25681        assert!(budget.selected_nodes < budget.candidate_nodes);
25682        assert!(
25683            budget.dropped_by_budget.iter().any(|drop| {
25684                drop.item == "node"
25685                    && drop.kind == "source_handle"
25686                    && drop.reason == "per_kind_quota"
25687            }),
25688            "expected source_handle budget drops, got {:?}",
25689            budget.dropped_by_budget
25690        );
25691        assert!(report.page.as_ref().unwrap().truncated);
25692        assert!(
25693            report
25694                .page
25695                .as_ref()
25696                .unwrap()
25697                .diagnostics
25698                .iter()
25699                .any(|diagnostic| diagnostic.contains("budget ranking signals")),
25700            "{:?}",
25701            report.page
25702        );
25703    }
25704
25705    #[test]
25706    fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
25707        let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
25708        let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
25709        for idx in 0..8 {
25710            let id = format!("far-{idx:02}");
25711            nodes.push(SubstrateGraphNode::new(
25712                id.clone(),
25713                "note",
25714                format!("aaa deeper row {idx}"),
25715            ));
25716            depth_by_id.insert(id, 6);
25717        }
25718
25719        let origin_ids = vec!["target".to_string()];
25720        let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
25721            &origin_ids,
25722            &BTreeMap::new(),
25723            nodes,
25724            Vec::new(),
25725            Some(3),
25726            Some(&depth_by_id),
25727            None,
25728        );
25729
25730        assert!(
25731            budgeted.nodes.iter().any(|node| node.id == "near"),
25732            "expected the shallow evidence row to outrank deeper rows, got {:?}",
25733            budgeted
25734                .nodes
25735                .iter()
25736                .map(|node| (&node.id, &node.label))
25737                .collect::<Vec<_>>()
25738        );
25739        assert!(
25740            budgeted.report.dropped_by_budget.iter().any(|drop| {
25741                drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
25742            }),
25743            "expected node quota drops, got {:?}",
25744            budgeted.report.dropped_by_budget
25745        );
25746        assert!(
25747            budgeted
25748                .report
25749                .diagnostics
25750                .iter()
25751                .any(|diagnostic| diagnostic.contains("depth")),
25752            "{:?}",
25753            budgeted.report.diagnostics
25754        );
25755    }
25756
25757    #[test]
25758    fn evidence_pagination_returns_next_cursor_when_truncated() {
25759        let mut nodes = vec![SubstrateGraphNode::new(
25760            "target".to_string(),
25761            "backlog_item",
25762            "target item".to_string(),
25763        )];
25764        let mut depth_by_id = BTreeMap::new();
25765        depth_by_id.insert("target".to_string(), 0);
25766        for idx in 0..20 {
25767            let id = format!("ev-{idx}");
25768            nodes.push(
25769                SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
25770                    .with_property("detail", "x".repeat(400)),
25771            );
25772            depth_by_id.insert(id, 1);
25773        }
25774        let origin_ids = vec!["target".to_string()];
25775        let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
25776            &origin_ids,
25777            &BTreeMap::new(),
25778            nodes.clone(),
25779            Vec::new(),
25780            Some(3),
25781            Some(&depth_by_id),
25782            None,
25783        );
25784        assert!(
25785            first_page.truncated,
25786            "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
25787            first_page.nodes.len(),
25788            first_page.report.candidate_nodes
25789        );
25790        assert!(
25791            first_page.next_cursor.is_some(),
25792            "expected next_cursor when truncated"
25793        );
25794        let cursor = first_page.next_cursor.unwrap();
25795        assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
25796        let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
25797        let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
25798            &origin_ids,
25799            &BTreeMap::new(),
25800            nodes.clone(),
25801            Vec::new(),
25802            Some(3),
25803            Some(&depth_by_id),
25804            Some(&cursor),
25805        );
25806        let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
25807        let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
25808        assert!(
25809            overlap.is_empty(),
25810            "pages should not overlap, but found shared ids: {overlap:?}"
25811        );
25812        assert!(
25813            second_page
25814                .report
25815                .diagnostics
25816                .iter()
25817                .any(|d| d.contains("cursor skipped")),
25818            "expected cursor skip diagnostic, got {:?}",
25819            second_page.report.diagnostics
25820        );
25821    }
25822
25823    #[test]
25824    fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
25825        let mut nodes = vec![SubstrateGraphNode::new(
25826            "target".to_string(),
25827            "backlog_item",
25828            "target item".to_string(),
25829        )];
25830        let mut depth_by_id = BTreeMap::new();
25831        depth_by_id.insert("target".to_string(), 0);
25832        for idx in 0..3 {
25833            let id = format!("ev-{idx}");
25834            nodes.push(SubstrateGraphNode::new(
25835                id.clone(),
25836                "source_handle",
25837                format!("evidence row {idx}"),
25838            ));
25839            depth_by_id.insert(id, 1);
25840        }
25841        let origin_ids = vec!["target".to_string()];
25842        let result = graph_db_apply_output_budget_with_depths_and_cursor(
25843            &origin_ids,
25844            &BTreeMap::new(),
25845            nodes,
25846            Vec::new(),
25847            None,
25848            Some(&depth_by_id),
25849            None,
25850        );
25851        assert!(
25852            !result.truncated,
25853            "expected no truncation with small candidate set and default budget"
25854        );
25855        assert!(
25856            result.next_cursor.is_none(),
25857            "expected no next_cursor when not truncated"
25858        );
25859    }
25860
25861    #[test]
25862    fn evidence_pagination_invalid_cursor_returns_first_page() {
25863        let mut nodes = vec![SubstrateGraphNode::new(
25864            "target".to_string(),
25865            "backlog_item",
25866            "target item".to_string(),
25867        )];
25868        let mut depth_by_id = BTreeMap::new();
25869        depth_by_id.insert("target".to_string(), 0);
25870        for idx in 0..5 {
25871            let id = format!("ev-{idx}");
25872            nodes.push(SubstrateGraphNode::new(
25873                id.clone(),
25874                "source_handle",
25875                format!("evidence row {idx}"),
25876            ));
25877            depth_by_id.insert(id, 1);
25878        }
25879        let origin_ids = vec!["target".to_string()];
25880        let result = graph_db_apply_output_budget_with_depths_and_cursor(
25881            &origin_ids,
25882            &BTreeMap::new(),
25883            nodes.clone(),
25884            Vec::new(),
25885            None,
25886            Some(&depth_by_id),
25887            Some("nonexistent-id"),
25888        );
25889        assert!(
25890            result
25891                .report
25892                .diagnostics
25893                .iter()
25894                .any(|d| d.contains("cursor skipped 0")),
25895            "invalid cursor should skip 0 candidates, got {:?}",
25896            result.report.diagnostics
25897        );
25898    }
25899
25900    #[test]
25901    fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25902        let dir = setup_traversal_project();
25903        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25904        let graph_db = dir.path().join(".tsift/graph.db");
25905        let _lock = hold_rollback_journal_lock(&graph_db);
25906
25907        let report =
25908            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25909                .unwrap();
25910
25911        assert_eq!(report.status, "current");
25912        assert_eq!(
25913            report.recovery,
25914            Some(index::ReadOnlyRecovery::SnapshotFallback)
25915        );
25916        assert!(
25917            report
25918                .warnings
25919                .iter()
25920                .any(|warning| warning.contains("rollback-journal lock")),
25921            "expected rollback-journal recovery warning, got {:?}",
25922            report.warnings
25923        );
25924    }
25925
25926    #[test]
25927    fn graph_db_status_copies_wal_sidecars_when_locked() {
25928        let dir = setup_traversal_project();
25929        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25930        let graph_db = dir.path().join(".tsift/graph.db");
25931        let _lock = hold_wal_database_lock(&graph_db);
25932
25933        let report =
25934            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25935                .unwrap();
25936
25937        assert_eq!(report.status, "current");
25938        assert_eq!(
25939            report.recovery,
25940            Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25941        );
25942        assert!(
25943            report
25944                .warnings
25945                .iter()
25946                .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25947            "expected WAL recovery warning, got {:?}",
25948            report.warnings
25949        );
25950    }
25951
25952    #[test]
25953    fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
25954        let dir = setup_traversal_project();
25955        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25956        let graph_db = dir.path().join(".tsift/graph.db");
25957        let _lock = hold_rollback_journal_lock(&graph_db);
25958
25959        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25960        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25961        report.finalize();
25962
25963        assert_eq!(report.status, "ok");
25964        assert!(!report.fail_closed);
25965        let recovery_check = report
25966            .checks
25967            .iter()
25968            .find(|check| check.name == "sqlite_graph_db_read_recovery")
25969            .expect("doctor should include read recovery diagnostic");
25970        assert_eq!(recovery_check.status, "recovered");
25971        assert!(
25972            recovery_check
25973                .diagnostics
25974                .iter()
25975                .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
25976            "expected rollback-journal recovery diagnostic, got {:?}",
25977            recovery_check.diagnostics
25978        );
25979    }
25980
25981    #[test]
25982    fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
25983        let dir = setup_traversal_project();
25984        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25985        let graph_db = dir.path().join(".tsift/graph.db");
25986        let _lock = hold_wal_database_lock(&graph_db);
25987
25988        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25989        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25990        report.finalize();
25991
25992        assert_eq!(report.status, "ok");
25993        assert!(!report.fail_closed);
25994        let recovery_check = report
25995            .checks
25996            .iter()
25997            .find(|check| check.name == "sqlite_graph_db_read_recovery")
25998            .expect("doctor should include WAL read recovery diagnostic");
25999        assert_eq!(recovery_check.status, "recovered");
26000        assert!(
26001            recovery_check
26002                .diagnostics
26003                .iter()
26004                .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
26005            "expected WAL recovery diagnostic, got {:?}",
26006            recovery_check.diagnostics
26007        );
26008    }
26009
26010    #[test]
26011    fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
26012        let dir = setup_traversal_project();
26013        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26014        let artifact = dir.path().join("graph.db.gz");
26015
26016        let exported =
26017            commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
26018                .unwrap();
26019        let exported_projection_version = exported.freshness.projection_version.clone();
26020        let exported_content_hash = exported.freshness.content_hash.clone();
26021        let exported_source_watermark = exported.freshness.source_watermark.clone();
26022        let exported_nodes = exported.counts.nodes;
26023        let exported_edges = exported.counts.edges;
26024        assert_eq!(exported.operation, "snapshot-export");
26025        assert!(exported.status.starts_with("exported"));
26026        assert!(artifact.exists());
26027        assert!(exported.artifact_bytes > 0);
26028        assert_eq!(exported.compression, "gzip");
26029
26030        fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
26031
26032        let imported =
26033            commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
26034                .unwrap();
26035        assert_eq!(imported.operation, "snapshot-import");
26036        assert!(imported.status.starts_with("imported"));
26037        assert_eq!(
26038            imported.freshness.projection_version,
26039            exported_projection_version
26040        );
26041        assert_eq!(imported.freshness.content_hash, exported_content_hash);
26042        assert_eq!(
26043            imported.freshness.source_watermark,
26044            exported_source_watermark
26045        );
26046        assert_eq!(imported.counts.nodes, exported_nodes);
26047        assert_eq!(imported.counts.edges, exported_edges);
26048        assert!(dir.path().join(".tsift/graph.db").exists());
26049    }
26050
26051    #[test]
26052    fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
26053        let dir = setup_traversal_project();
26054        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26055        let graph_db = dir.path().join(".tsift/graph.db");
26056        let _lock = hold_wal_database_lock(&graph_db);
26057
26058        let err = match commands::infra::graph_db_snapshot_export_report(
26059            dir.path(),
26060            None,
26061            &dir.path().join("graph.db.gz"),
26062            false,
26063        ) {
26064            Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
26065            Err(err) => err,
26066        };
26067
26068        // The resilient open succeeds via the recovery fallback in this WAL-lock
26069        // case, so the live-lock signal surfaces at the recovery gate. Its
26070        // wording is now unified with the `map_live_lock` open-path diagnostic so
26071        // the operator gets the same actionable guidance regardless of which gate
26072        // trips (#tsreviewcleanup).
26073        let message = err.to_string();
26074        assert!(
26075            message.contains("recovered path")
26076                && message.contains("database is locked")
26077                && message.contains("wait for it to finish before retrying the export"),
26078            "expected unified live-lock recovery diagnostic, got {err:#}"
26079        );
26080    }
26081
26082    #[test]
26083    fn graph_db_snapshot_clean_export_maps_database_locked_to_live_lock_diagnostic() {
26084        let dir = setup_traversal_project();
26085        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26086        let graph_db = dir.path().join(".tsift/graph.db");
26087
26088        // Hold a plain EXCLUSIVE lock with no recovery sidecar so the export
26089        // clears the recovery fail-closed gate and reaches VACUUM INTO, which
26090        // then fails with a raw SQLite "database is locked".
26091        let blocker = Connection::open(&graph_db).unwrap();
26092        blocker
26093            .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26094            .unwrap();
26095        assert!(!substrate::rollback_journal_path(&graph_db).exists());
26096
26097        let clean_path = dir.path().join("graph-clean-export.db");
26098        let err = match commands::infra::graph_db_snapshot_clean_export_copy(&graph_db, &clean_path)
26099        {
26100            Ok(bytes) => panic!("expected export to fail under live lock, got {bytes} bytes"),
26101            Err(err) => err,
26102        };
26103
26104        let message = err.to_string();
26105        assert!(
26106            message.contains("concurrent graph-db refresh or snapshot-import is in progress"),
26107            "expected actionable live-lock diagnostic, got {err:#}"
26108        );
26109        assert!(
26110            message.contains("wait for it to finish before retrying"),
26111            "expected retry guidance, got {err:#}"
26112        );
26113        // The raw SQLite phrasing must not leak as the surfaced top-level error.
26114        assert!(
26115            !message.contains("creating clean graph-db export copy"),
26116            "live-lock case must not surface the generic VACUUM context, got {err:#}"
26117        );
26118
26119        drop(blocker);
26120    }
26121
26122    #[test]
26123    fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
26124        let dir = setup_traversal_project();
26125        let session = dir.path().join("tasks/software/tsift.md");
26126        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
26127        let graph_db = dir.path().join(".tsift/graph.db");
26128        let _lock = hold_rollback_journal_lock(&graph_db);
26129
26130        let result = cmd_graph_db(
26131            &session,
26132            None,
26133            GraphDbBackend::Sqlite,
26134            None,
26135            GraphDbQuery::Evidence {
26136                target: "kgnv".to_string(),
26137                depth: 3,
26138                limit: 8,
26139                cursor: None,
26140            },
26141            OutputFormat {
26142                json_output: false,
26143                compact: true,
26144                pretty: false,
26145                terse: false,
26146                ultra_terse: false,
26147                schema: false,
26148                envelope: false,
26149            },
26150        );
26151
26152        assert!(result.is_ok());
26153    }
26154
26155    fn current_graph_db_freshness() -> GraphDbFreshnessReport {
26156        GraphDbFreshnessReport {
26157            status: "current".to_string(),
26158            fail_closed: false,
26159            projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
26160            content_hash: Some("fixture".to_string()),
26161            source_watermark: None,
26162            diagnostics: Vec::new(),
26163        }
26164    }
26165
26166    #[test]
26167    fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
26168        let dir = setup_traversal_project();
26169        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26170        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
26171        let stale = GraphDbFreshnessReport {
26172            status: "stale".to_string(),
26173            fail_closed: true,
26174            projection_version: Some("old-v0".to_string()),
26175            content_hash: None,
26176            source_watermark: None,
26177            diagnostics: vec!["projection content hash is missing".to_string()],
26178        };
26179
26180        let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
26181            root: dir.path(),
26182            scope: None,
26183            backend: "sqlite",
26184            target: "kgnv",
26185            preferred_path: None,
26186            depth: 3,
26187            limit: 8,
26188            cursor: None,
26189            store: &store,
26190            freshness: stale,
26191            warnings: Vec::new(),
26192        }) {
26193            Ok(_) => panic!("stale graph freshness should fail closed"),
26194            Err(err) => err,
26195        };
26196        let message = err.to_string();
26197        assert!(message.contains("failed closed"), "{message}");
26198        assert!(message.contains("graph-db --path"), "{message}");
26199        assert!(message.contains("refresh --json"), "{message}");
26200    }
26201
26202    fn paged_graph_ids(
26203        store: &impl GraphStore,
26204        cursor: Option<&str>,
26205    ) -> (Vec<String>, GraphDbPageReport) {
26206        let report = graph_db_report_from_store(
26207            Path::new("."),
26208            None,
26209            "fixture",
26210            GraphDbQuery::Kind {
26211                kind: "backlog".to_string(),
26212                cursor: cursor.map(str::to_string),
26213                limit: Some(2),
26214                property_filters: vec!["phase=open".to_string()],
26215            },
26216            store,
26217            current_graph_db_freshness(),
26218            Vec::new(),
26219        )
26220        .unwrap();
26221        (
26222            report.nodes.iter().map(|node| node.id.clone()).collect(),
26223            report.page.unwrap(),
26224        )
26225    }
26226
26227    #[test]
26228    fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
26229        let nodes = (0..5)
26230            .map(|idx| {
26231                let phase = if idx == 1 { "closed" } else { "open" };
26232                SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
26233                    .with_property("phase", phase)
26234            })
26235            .collect::<Vec<_>>();
26236        let projection = GraphProjection {
26237            nodes,
26238            edges: Vec::new(),
26239        };
26240        let sqlite = SqliteGraphStore::in_memory().unwrap();
26241        projection.upsert_into(&sqlite).unwrap();
26242        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
26243        projection.upsert_into(&convex).unwrap();
26244
26245        let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
26246        let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
26247        assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
26248        assert_eq!(sqlite_first_ids, convex_first_ids);
26249        assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
26250        assert!(sqlite_first_page.truncated);
26251        assert_eq!(
26252            sqlite_first_page.returned_nodes,
26253            convex_first_page.returned_nodes
26254        );
26255        assert_eq!(
26256            sqlite_first_page.property_filters,
26257            convex_first_page.property_filters
26258        );
26259        assert!(
26260            sqlite_first_page
26261                .diagnostics
26262                .iter()
26263                .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
26264            "expected SQLite kind query plan diagnostics, got {:?}",
26265            sqlite_first_page.diagnostics
26266        );
26267
26268        let cursor = sqlite_first_page.next_cursor.as_deref();
26269        let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
26270        let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
26271        assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
26272        assert_eq!(sqlite_next_ids, convex_next_ids);
26273        assert_eq!(sqlite_next_page.next_cursor, None);
26274        assert!(!sqlite_next_page.truncated);
26275        assert_eq!(
26276            sqlite_next_page.returned_nodes,
26277            convex_next_page.returned_nodes
26278        );
26279        assert_eq!(
26280            sqlite_next_page.property_filters,
26281            convex_next_page.property_filters
26282        );
26283    }
26284
26285    #[test]
26286    fn traversal_shortest_path_crosses_artifacts_and_symbols() {
26287        let dir = setup_traversal_project();
26288        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26289        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
26290        let main = resolve_traversal_node(&graph, "main").unwrap();
26291
26292        let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
26293        assert_eq!(path.first(), Some(&backlog.handle));
26294        assert_eq!(path.last(), Some(&main.handle));
26295        assert!(
26296            path.len() >= 3,
26297            "expected backlog -> symbol -> main, got {path:?}"
26298        );
26299    }
26300
26301    #[test]
26302    fn traversal_report_recommends_next_bugfix_nodes() {
26303        let dir = setup_traversal_project();
26304        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26305        let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
26306
26307        assert_eq!(report.mode, "neighborhood");
26308        assert!(
26309            report
26310                .recommendations
26311                .iter()
26312                .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
26313            "expected helper recommendation, got {:?}",
26314            report.recommendations
26315        );
26316        assert!(
26317            !report.exploration.source_windows.is_empty(),
26318            "expected exploration source windows"
26319        );
26320        assert!(
26321            report
26322                .exploration
26323                .no_reread_guidance
26324                .contains("avoid whole-file reads")
26325        );
26326    }
26327
26328    #[test]
26329    fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
26330        let dir = setup_traversal_project();
26331        std::thread::sleep(std::time::Duration::from_millis(50));
26332        std::fs::write(
26333            dir.path().join("main.rs"),
26334            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26335        )
26336        .unwrap();
26337
26338        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26339
26340        assert!(
26341            graph
26342                .warnings
26343                .iter()
26344                .any(|warning| warning.contains("index refreshed")
26345                    && warning.contains("graph traversal packet")),
26346            "expected refresh diagnostic, got {:?}",
26347            graph.warnings
26348        );
26349        assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
26350
26351        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26352        let summary = db.compute_changes(dir.path()).unwrap();
26353        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26354    }
26355
26356    #[test]
26357    fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
26358        let dir = setup_traversal_project();
26359        let db_path = dir.path().join(".tsift/index.db");
26360        let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
26361        std::thread::sleep(std::time::Duration::from_millis(50));
26362        std::fs::write(
26363            dir.path().join("main.rs"),
26364            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26365        )
26366        .unwrap();
26367
26368        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26369        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
26370
26371        assert!(
26372            graph
26373                .warnings
26374                .iter()
26375                .any(|warning| warning.contains("falling back to raw source file nodes")),
26376            "expected raw-source fallback diagnostic, got {:?}",
26377            graph.warnings
26378        );
26379        assert!(
26380            file.detail
26381                .as_deref()
26382                .is_some_and(|detail| detail.contains("raw source fallback")),
26383            "expected raw-source detail, got {:?}",
26384            file.detail
26385        );
26386        assert!(
26387            file.expand.contains("source-read"),
26388            "expected source-read fallback command, got {}",
26389            file.expand
26390        );
26391        assert!(
26392            resolve_traversal_node(&graph, "helper").is_none(),
26393            "stale symbol evidence should be skipped when refresh is blocked"
26394        );
26395    }
26396
26397    #[test]
26398    fn traversal_cmd_supports_json_and_html_outputs() {
26399        let dir = setup_traversal_project();
26400        cmd_traverse(
26401            Some("#kgnv"),
26402            Some("main"),
26403            dir.path(),
26404            None,
26405            1,
26406            50,
26407            TraverseFormat::Json,
26408            false,
26409            false,
26410            false,
26411            None,
26412        )
26413        .unwrap();
26414        cmd_traverse(
26415            None,
26416            None,
26417            dir.path(),
26418            None,
26419            1,
26420            50,
26421            TraverseFormat::Html,
26422            false,
26423            false,
26424            false,
26425            None,
26426        )
26427        .unwrap();
26428    }
26429
26430    #[test]
26431    fn traversal_html_renders_inline_graph_visualization() {
26432        let dir = setup_traversal_project();
26433        seed_traversal_semantic_summaries(dir.path());
26434        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26435        let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
26436        let html = traversal_report_html(&report).unwrap();
26437
26438        assert!(html.contains("id=\"graph-canvas\""));
26439        assert!(html.contains("semantic_concept"));
26440        assert!(html.contains("graph navigation"));
26441        assert!(html.contains("JSON.parse"));
26442    }
26443
26444    #[test]
26445    fn compact_helpers_trim_scores_and_snippets() {
26446        assert_eq!(format_score(0.12345, true), "0.12");
26447        assert_eq!(format_score(0.12345, false), "0.1235");
26448        let snippet = compact_snippet("    first line with useful context\nsecond");
26449        assert_eq!(snippet.as_deref(), Some("first line with useful context"));
26450    }
26451
26452    #[test]
26453    fn compact_members_caps_list() {
26454        let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
26455            .iter()
26456            .map(|n| graph::CommunityMember::new(*n))
26457            .collect();
26458        assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
26459    }
26460
26461    #[test]
26462    fn abbreviate_kind_maps_common_kinds() {
26463        assert_eq!(abbreviate_kind("function"), "fn");
26464        assert_eq!(abbreviate_kind("method"), "meth");
26465        assert_eq!(abbreviate_kind("class"), "cls");
26466        assert_eq!(abbreviate_kind("interface"), "iface");
26467        assert_eq!(abbreviate_kind("type_alias"), "type");
26468        assert_eq!(abbreviate_kind("data_class"), "data_cls");
26469        assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
26470        assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
26471        assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
26472        assert_eq!(abbreviate_kind("object"), "obj");
26473        assert_eq!(abbreviate_kind("heading"), "h");
26474        assert_eq!(abbreviate_kind("code_block"), "code");
26475        // short kinds pass through
26476        assert_eq!(abbreviate_kind("struct"), "struct");
26477        assert_eq!(abbreviate_kind("trait"), "trait");
26478        assert_eq!(abbreviate_kind("enum"), "enum");
26479        assert_eq!(abbreviate_kind("const"), "const");
26480        assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
26481    }
26482
26483    #[test]
26484    fn abbreviate_match_type_maps_search_types() {
26485        assert_eq!(abbreviate_match_type("exact_name"), "exact");
26486        assert_eq!(abbreviate_match_type("partial_tags"), "partial");
26487        assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
26488        assert_eq!(abbreviate_match_type("other_type"), "other_type");
26489    }
26490
26491    #[test]
26492    fn explain_compact_groups_edges_by_file() {
26493        let edges = vec![
26494            index::StoredEdge {
26495                caller_file: "src/main.rs".to_string(),
26496                caller_name: "main".to_string(),
26497                caller_line: 1,
26498                callee_name: "helper".to_string(),
26499                call_site_line: 2,
26500                tagpath_handle: None,
26501            },
26502            index::StoredEdge {
26503                caller_file: "src/main.rs".to_string(),
26504                caller_name: "main".to_string(),
26505                caller_line: 1,
26506                callee_name: "render".to_string(),
26507                call_site_line: 3,
26508                tagpath_handle: None,
26509            },
26510        ];
26511        let lines = format_edge_groups(&edges, false);
26512        assert_eq!(lines, vec!["  src/main.rs (2): helper, render"]);
26513    }
26514
26515    #[test]
26516    fn search_hit_groups_preserve_file_counts_and_samples() {
26517        let dir = tempfile::tempdir().unwrap();
26518        let root = dir.path();
26519        let main_rs = root.join("src/main.rs");
26520        fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
26521        fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
26522        let freshness = exact_search_file_timestamp(&main_rs);
26523        let hits = vec![
26524            sift::SearchHit {
26525                artifact_id: "a".to_string(),
26526                artifact_kind: sift::ContextArtifactKind::File,
26527                path: main_rs.display().to_string(),
26528                rank: 1,
26529                score: 10.0,
26530                confidence: sift::ScoreConfidence::High,
26531                location: Some("line 3".to_string()),
26532                snippet: "claudescore-3 anchor".to_string(),
26533                provenance: sift::ArtifactProvenance {
26534                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26535                    source: "ripgrep -F".to_string(),
26536                    synthetic: false,
26537                },
26538                freshness: freshness.clone(),
26539                budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
26540            },
26541            sift::SearchHit {
26542                artifact_id: "b".to_string(),
26543                artifact_kind: sift::ContextArtifactKind::File,
26544                path: main_rs.display().to_string(),
26545                rank: 2,
26546                score: 9.0,
26547                confidence: sift::ScoreConfidence::High,
26548                location: Some("line 7".to_string()),
26549                snippet: "claudescore-3 follow-up".to_string(),
26550                provenance: sift::ArtifactProvenance {
26551                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26552                    source: "ripgrep -F".to_string(),
26553                    synthetic: false,
26554                },
26555                freshness: freshness.clone(),
26556                budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
26557            },
26558            sift::SearchHit {
26559                artifact_id: "c".to_string(),
26560                artifact_kind: sift::ContextArtifactKind::File,
26561                path: main_rs.display().to_string(),
26562                rank: 3,
26563                score: 8.0,
26564                confidence: sift::ScoreConfidence::High,
26565                location: Some("line 9".to_string()),
26566                snippet: "claudescore-3 tail".to_string(),
26567                provenance: sift::ArtifactProvenance {
26568                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26569                    source: "ripgrep -F".to_string(),
26570                    synthetic: false,
26571                },
26572                freshness,
26573                budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
26574            },
26575        ];
26576
26577        let groups = group_search_hits(&hits, root, false);
26578        assert_eq!(groups.len(), 1);
26579        assert_eq!(groups[0].path, "src/main.rs");
26580        assert_eq!(groups[0].hits, 3);
26581        assert_eq!(
26582            groups[0].samples,
26583            vec![
26584                "line 3: claudescore-3 anchor".to_string(),
26585                "line 7: claudescore-3 follow-up".to_string()
26586            ]
26587        );
26588        assert!(should_collapse_search_hits(&hits, root, false));
26589    }
26590
26591    #[test]
26592    fn dense_edge_groups_trigger_collapse() {
26593        let edges = vec![
26594            index::StoredEdge {
26595                caller_file: "src/main.rs".to_string(),
26596                caller_name: "main".to_string(),
26597                caller_line: 1,
26598                callee_name: "helper".to_string(),
26599                call_site_line: 2,
26600                tagpath_handle: None,
26601            },
26602            index::StoredEdge {
26603                caller_file: "src/main.rs".to_string(),
26604                caller_name: "beta".to_string(),
26605                caller_line: 5,
26606                callee_name: "helper".to_string(),
26607                call_site_line: 6,
26608                tagpath_handle: None,
26609            },
26610            index::StoredEdge {
26611                caller_file: "src/main.rs".to_string(),
26612                caller_name: "gamma".to_string(),
26613                caller_line: 9,
26614                callee_name: "helper".to_string(),
26615                call_site_line: 10,
26616                tagpath_handle: None,
26617            },
26618        ];
26619        assert!(should_collapse_edge_groups(&edges));
26620    }
26621
26622    // --- workspace indexing ---
26623
26624    fn setup_workspace() -> tempfile::TempDir {
26625        let dir = tempfile::tempdir().unwrap();
26626        let root = dir.path();
26627        std::fs::write(
26628            root.join(".gitmodules"),
26629            r#"[submodule "src/alpha"]
26630	path = src/alpha
26631	url = https://example.com/alpha
26632[submodule "src/beta"]
26633	path = src/beta
26634	url = https://example.com/beta
26635"#,
26636        )
26637        .unwrap();
26638        let alpha = root.join("src/alpha");
26639        let beta = root.join("src/beta");
26640        std::fs::create_dir_all(&alpha).unwrap();
26641        std::fs::create_dir_all(&beta).unwrap();
26642        std::fs::write(
26643            alpha.join("lib.rs"),
26644            "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
26645        )
26646        .unwrap();
26647        std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
26648        dir
26649    }
26650
26651    fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
26652        let dir = tempfile::tempdir().unwrap();
26653        let root = dir.path();
26654        std::fs::write(
26655            root.join(".gitmodules"),
26656            r#"[submodule "pkg/app/foo"]
26657	path = pkg/app/foo
26658	url = https://example.com/pkg-app-foo
26659[submodule "vendor/foo"]
26660	path = vendor/foo
26661	url = https://example.com/vendor-foo
26662"#,
26663        )
26664        .unwrap();
26665        let pkg_foo = root.join("pkg/app/foo");
26666        let vendor_foo = root.join("vendor/foo");
26667        std::fs::create_dir_all(&pkg_foo).unwrap();
26668        std::fs::create_dir_all(&vendor_foo).unwrap();
26669        std::fs::write(
26670            pkg_foo.join("lib.rs"),
26671            "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
26672        )
26673        .unwrap();
26674        std::fs::write(
26675            vendor_foo.join("lib.rs"),
26676            "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
26677        )
26678        .unwrap();
26679        dir
26680    }
26681
26682    #[test]
26683    fn workspace_index_creates_per_submodule_dbs() {
26684        let dir = setup_workspace();
26685        cmd_index(
26686            dir.path(),
26687            false,
26688            false,
26689            false,
26690            false,
26691            false,
26692            true,
26693            None,
26694            false,
26695            false,
26696            false,
26697            false,
26698            false,
26699            false,
26700        )
26701        .unwrap();
26702        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26703        assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
26704    }
26705
26706    #[test]
26707    fn workspace_index_single_submodule() {
26708        let dir = setup_workspace();
26709        cmd_index(
26710            dir.path(),
26711            false,
26712            false,
26713            false,
26714            false,
26715            false,
26716            false,
26717            Some("alpha"),
26718            false,
26719            false,
26720            false,
26721            false,
26722            false,
26723            false,
26724        )
26725        .unwrap();
26726        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26727        assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
26728    }
26729
26730    #[test]
26731    fn workspace_index_single_submodule_errors_on_unknown_scope() {
26732        let dir = setup_workspace();
26733
26734        let err = cmd_index(
26735            dir.path(),
26736            false,
26737            false,
26738            false,
26739            false,
26740            false,
26741            false,
26742            Some("missing"),
26743            false,
26744            false,
26745            false,
26746            false,
26747            false,
26748            false,
26749        )
26750        .unwrap_err();
26751
26752        let msg = err.to_string();
26753        assert!(msg.contains("unknown scope `missing`"));
26754        assert!(msg.contains("Available scopes: alpha, beta"));
26755        assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
26756    }
26757
26758    #[test]
26759    fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
26760        let dir = setup_workspace_with_duplicate_leaf_names();
26761        cmd_index(
26762            dir.path(),
26763            false,
26764            false,
26765            false,
26766            false,
26767            false,
26768            true,
26769            None,
26770            false,
26771            false,
26772            false,
26773            false,
26774            false,
26775            false,
26776        )
26777        .unwrap();
26778
26779        assert!(
26780            dir.path()
26781                .join(".tsift/indexes/pkg/app/foo/index.db")
26782                .exists()
26783        );
26784        assert!(
26785            dir.path()
26786                .join(".tsift/indexes/vendor/foo/index.db")
26787                .exists()
26788        );
26789    }
26790
26791    #[test]
26792    fn federated_search_across_submodules() {
26793        let dir = setup_workspace();
26794        cmd_index(
26795            dir.path(),
26796            false,
26797            false,
26798            false,
26799            false,
26800            false,
26801            true,
26802            None,
26803            false,
26804            false,
26805            false,
26806            false,
26807            false,
26808            false,
26809        )
26810        .unwrap();
26811        let (hits, _diag) = federated_symbol_search(
26812            dir.path(),
26813            "alpha_helper",
26814            10,
26815            &TagpathSearchOpts {
26816                no_tagpath: true,
26817                strict: false,
26818            },
26819        )
26820        .unwrap();
26821        assert!(
26822            !hits.is_empty(),
26823            "should find alpha_helper via federated search"
26824        );
26825    }
26826
26827    #[test]
26828    fn federated_search_respects_isolation() {
26829        let dir = setup_workspace();
26830        let tsift_dir = dir.path().join(".tsift");
26831        std::fs::create_dir_all(&tsift_dir).unwrap();
26832        std::fs::write(
26833            tsift_dir.join("config.toml"),
26834            r#"
26835[overrides.alpha]
26836tier = "isolated"
26837"#,
26838        )
26839        .unwrap();
26840        cmd_index(
26841            dir.path(),
26842            false,
26843            false,
26844            false,
26845            false,
26846            false,
26847            true,
26848            None,
26849            false,
26850            false,
26851            false,
26852            false,
26853            false,
26854            false,
26855        )
26856        .unwrap();
26857        let (hits, _diag) = federated_symbol_search(
26858            dir.path(),
26859            "alpha_helper",
26860            10,
26861            &TagpathSearchOpts {
26862                no_tagpath: true,
26863                strict: false,
26864            },
26865        )
26866        .unwrap();
26867        assert!(
26868            hits.is_empty(),
26869            "isolated submodule should not appear in federated search"
26870        );
26871    }
26872
26873    #[test]
26874    fn federated_lexical_search_respects_isolation() {
26875        let dir = setup_workspace();
26876        let tsift_dir = dir.path().join(".tsift");
26877        std::fs::create_dir_all(&tsift_dir).unwrap();
26878        std::fs::write(
26879            tsift_dir.join("config.toml"),
26880            r#"
26881[overrides.alpha]
26882tier = "isolated"
26883"#,
26884        )
26885        .unwrap();
26886        cmd_index(
26887            dir.path(),
26888            false,
26889            false,
26890            false,
26891            false,
26892            false,
26893            true,
26894            None,
26895            false,
26896            false,
26897            false,
26898            false,
26899            false,
26900            false,
26901        )
26902        .unwrap();
26903
26904        let response = federated_sift_search(
26905            dir.path(),
26906            &dir.path().join(".tsift/search-cache"),
26907            "fn",
26908            10,
26909            0,
26910            "lexical",
26911            None,
26912        )
26913        .unwrap();
26914
26915        assert!(
26916            !response.hits.is_empty(),
26917            "shared scopes should still contribute lexical hits"
26918        );
26919        assert!(
26920            response
26921                .hits
26922                .iter()
26923                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26924            "isolated scope should not leak lexical hits: {:?}",
26925            response.hits
26926        );
26927    }
26928
26929    #[test]
26930    fn federated_lexical_search_respects_private_tier() {
26931        let dir = setup_workspace();
26932        let tsift_dir = dir.path().join(".tsift");
26933        std::fs::create_dir_all(&tsift_dir).unwrap();
26934        std::fs::write(
26935            tsift_dir.join("config.toml"),
26936            r#"
26937[overrides.alpha]
26938tier = "private"
26939"#,
26940        )
26941        .unwrap();
26942        cmd_index(
26943            dir.path(),
26944            false,
26945            false,
26946            false,
26947            false,
26948            false,
26949            true,
26950            None,
26951            false,
26952            false,
26953            false,
26954            false,
26955            false,
26956            false,
26957        )
26958        .unwrap();
26959
26960        let response = federated_sift_search(
26961            dir.path(),
26962            &dir.path().join(".tsift/search-cache"),
26963            "fn",
26964            10,
26965            0,
26966            "lexical",
26967            None,
26968        )
26969        .unwrap();
26970
26971        assert!(
26972            !response.hits.is_empty(),
26973            "shared scopes should still contribute lexical hits"
26974        );
26975        assert!(
26976            response
26977                .hits
26978                .iter()
26979                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26980            "private scope should not leak lexical hits: {:?}",
26981            response.hits
26982        );
26983    }
26984
26985    #[test]
26986    fn scoped_search_finds_submodule_symbols() {
26987        let dir = setup_workspace();
26988        cmd_index(
26989            dir.path(),
26990            false,
26991            false,
26992            false,
26993            false,
26994            false,
26995            true,
26996            None,
26997            false,
26998            false,
26999            false,
27000            false,
27001            false,
27002            false,
27003        )
27004        .unwrap();
27005        let cfg = config::Config::load(dir.path()).unwrap();
27006        let db_path = cfg.db_path_for(dir.path(), "alpha");
27007        let db = index::IndexDb::open(&db_path).unwrap();
27008        let hits = db.symbol_search("alpha_main", 10).unwrap();
27009        assert!(!hits.is_empty());
27010        assert_eq!(hits[0].name, "alpha_main");
27011    }
27012
27013    #[test]
27014    fn scoped_search_cmd_errors_on_unknown_scope() {
27015        let dir = setup_workspace();
27016
27017        let err = cmd_search(
27018            "alpha_main".to_string(),
27019            Some(dir.path().to_path_buf()),
27020            5,
27021            Some("lexical".to_string()),
27022            Some("missing".to_string()),
27023            false,
27024            false,
27025            false,
27026            0,
27027            false,
27028            false,
27029            false,
27030            false,
27031            false,
27032            false,
27033            false,
27034        )
27035        .unwrap_err();
27036
27037        let msg = err.to_string();
27038        assert!(msg.contains("unknown scope `missing`"));
27039        assert!(msg.contains("Available scopes: alpha, beta"));
27040    }
27041
27042    #[test]
27043    fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
27044        let dir = setup_workspace_with_duplicate_leaf_names();
27045        cmd_index(
27046            dir.path(),
27047            false,
27048            false,
27049            false,
27050            false,
27051            false,
27052            true,
27053            None,
27054            false,
27055            false,
27056            false,
27057            false,
27058            false,
27059            false,
27060        )
27061        .unwrap();
27062
27063        let err = cmd_search(
27064            "vendor_only".to_string(),
27065            Some(dir.path().to_path_buf()),
27066            5,
27067            Some("lexical".to_string()),
27068            Some("foo".to_string()),
27069            false,
27070            false,
27071            false,
27072            0,
27073            false,
27074            false,
27075            false,
27076            false,
27077            false,
27078            false,
27079            false,
27080        )
27081        .unwrap_err();
27082
27083        let msg = err.to_string();
27084        assert!(msg.contains("ambiguous scope `foo`"));
27085        assert!(msg.contains("pkg/app/foo"));
27086        assert!(msg.contains("vendor/foo"));
27087    }
27088
27089    #[test]
27090    fn scoped_graph_query() {
27091        let dir = setup_workspace();
27092        cmd_index(
27093            dir.path(),
27094            false,
27095            false,
27096            false,
27097            false,
27098            false,
27099            true,
27100            None,
27101            false,
27102            false,
27103            false,
27104            false,
27105            false,
27106            false,
27107        )
27108        .unwrap();
27109        let cfg = config::Config::load(dir.path()).unwrap();
27110        let db_path = cfg.db_path_for(dir.path(), "alpha");
27111        let db = index::IndexDb::open(&db_path).unwrap();
27112        let callees = db.callees_of("alpha_main").unwrap();
27113        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
27114        assert!(names.contains(&"alpha_helper"));
27115    }
27116
27117    fn assert_workspace_query_requires_scope(err: anyhow::Error) {
27118        let msg = err.to_string();
27119        assert!(msg.contains("require `--scope <scope>`"), "{msg}");
27120        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27121        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27122        assert!(
27123            !msg.contains("no index found at"),
27124            "workspace query should fail with scope guidance, got: {msg}"
27125        );
27126    }
27127
27128    fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
27129        let msg = err.to_string();
27130        assert!(
27131            msg.contains("requires `--scope <scope>` or `--federated`"),
27132            "{msg}"
27133        );
27134        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27135        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27136        assert!(
27137            !msg.contains("autoindexing index"),
27138            "workspace search should fail before creating a shared root index: {msg}"
27139        );
27140    }
27141
27142    #[test]
27143    fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
27144        let dir = setup_workspace();
27145        cmd_index(
27146            dir.path(),
27147            false,
27148            false,
27149            false,
27150            false,
27151            false,
27152            true,
27153            None,
27154            false,
27155            false,
27156            false,
27157            false,
27158            false,
27159            false,
27160        )
27161        .unwrap();
27162
27163        let err = cmd_graph(
27164            "alpha_main",
27165            dir.path(),
27166            false,
27167            false,
27168            None,
27169            20,
27170            false,
27171            false,
27172            false,
27173            false,
27174            false,
27175            false,
27176            false,
27177            TagpathSearchOpts::default(),
27178        )
27179        .unwrap_err();
27180
27181        assert_workspace_query_requires_scope(err);
27182    }
27183
27184    #[test]
27185    fn graph_cmd_infers_scope_from_nested_workspace_path() {
27186        let dir = setup_workspace();
27187        cmd_index(
27188            dir.path(),
27189            false,
27190            false,
27191            false,
27192            false,
27193            false,
27194            true,
27195            None,
27196            false,
27197            false,
27198            false,
27199            false,
27200            false,
27201            false,
27202        )
27203        .unwrap();
27204        let nested = dir.path().join("src/alpha/nested");
27205        std::fs::create_dir_all(&nested).unwrap();
27206
27207        let result = cmd_graph(
27208            "alpha_main",
27209            &nested,
27210            false,
27211            false,
27212            None,
27213            20,
27214            false,
27215            false,
27216            false,
27217            false,
27218            false,
27219            false,
27220            false,
27221            TagpathSearchOpts::default(),
27222        );
27223
27224        assert!(result.is_ok());
27225    }
27226
27227    #[test]
27228    fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
27229        let dir = setup_workspace();
27230        cmd_index(
27231            dir.path(),
27232            false,
27233            false,
27234            false,
27235            false,
27236            false,
27237            true,
27238            None,
27239            false,
27240            false,
27241            false,
27242            false,
27243            false,
27244            false,
27245        )
27246        .unwrap();
27247
27248        let err = cmd_communities(
27249            dir.path(),
27250            None,
27251            1,
27252            10,
27253            false,
27254            false,
27255            false,
27256            false,
27257            false,
27258            false,
27259            TagpathSearchOpts::default(),
27260        )
27261        .unwrap_err();
27262
27263        assert_workspace_query_requires_scope(err);
27264    }
27265
27266    #[test]
27267    fn communities_cmd_infers_scope_from_nested_workspace_path() {
27268        let dir = setup_workspace();
27269        cmd_index(
27270            dir.path(),
27271            false,
27272            false,
27273            false,
27274            false,
27275            false,
27276            true,
27277            None,
27278            false,
27279            false,
27280            false,
27281            false,
27282            false,
27283            false,
27284        )
27285        .unwrap();
27286        let nested = dir.path().join("src/alpha/nested");
27287        std::fs::create_dir_all(&nested).unwrap();
27288
27289        let result = cmd_communities(
27290            &nested,
27291            None,
27292            1,
27293            10,
27294            false,
27295            false,
27296            false,
27297            false,
27298            false,
27299            false,
27300            TagpathSearchOpts::default(),
27301        );
27302
27303        assert!(result.is_ok());
27304    }
27305
27306    #[test]
27307    fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
27308        let dir = setup_workspace();
27309        cmd_index(
27310            dir.path(),
27311            false,
27312            false,
27313            false,
27314            false,
27315            false,
27316            true,
27317            None,
27318            false,
27319            false,
27320            false,
27321            false,
27322            false,
27323            false,
27324        )
27325        .unwrap();
27326
27327        let err = cmd_path(
27328            "alpha_main",
27329            "alpha_helper",
27330            dir.path(),
27331            None,
27332            false,
27333            false,
27334            false,
27335            false,
27336            false,
27337            TagpathSearchOpts::default(),
27338        )
27339        .unwrap_err();
27340
27341        assert_workspace_query_requires_scope(err);
27342    }
27343
27344    #[test]
27345    fn path_cmd_infers_scope_from_nested_workspace_path() {
27346        let dir = setup_workspace();
27347        cmd_index(
27348            dir.path(),
27349            false,
27350            false,
27351            false,
27352            false,
27353            false,
27354            true,
27355            None,
27356            false,
27357            false,
27358            false,
27359            false,
27360            false,
27361            false,
27362        )
27363        .unwrap();
27364        let nested = dir.path().join("src/alpha/nested");
27365        std::fs::create_dir_all(&nested).unwrap();
27366
27367        let result = cmd_path(
27368            "alpha_main",
27369            "alpha_helper",
27370            &nested,
27371            None,
27372            false,
27373            false,
27374            false,
27375            false,
27376            false,
27377            TagpathSearchOpts::default(),
27378        );
27379
27380        assert!(result.is_ok());
27381    }
27382
27383    #[test]
27384    fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27385        let dir = setup_graph_index();
27386        let db_path = dir.path().join(".tsift/index.db");
27387        let _lock = hold_rollback_journal_lock(&db_path);
27388
27389        let result = cmd_path(
27390            "main",
27391            "helper",
27392            dir.path(),
27393            None,
27394            false,
27395            false,
27396            false,
27397            false,
27398            false,
27399            TagpathSearchOpts::default(),
27400        );
27401
27402        assert!(result.is_ok());
27403    }
27404
27405    #[test]
27406    fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
27407        let dir = setup_workspace();
27408        cmd_index(
27409            dir.path(),
27410            false,
27411            false,
27412            false,
27413            false,
27414            false,
27415            true,
27416            None,
27417            false,
27418            false,
27419            false,
27420            false,
27421            false,
27422            false,
27423        )
27424        .unwrap();
27425
27426        let err = cmd_explain(
27427            "alpha_main",
27428            dir.path(),
27429            None,
27430            15,
27431            false,
27432            false,
27433            false,
27434            false,
27435            false,
27436            false,
27437            false,
27438            false,
27439        )
27440        .unwrap_err();
27441
27442        assert_workspace_query_requires_scope(err);
27443    }
27444
27445    #[test]
27446    fn explain_cmd_infers_scope_from_nested_workspace_path() {
27447        let dir = setup_workspace();
27448        cmd_index(
27449            dir.path(),
27450            false,
27451            false,
27452            false,
27453            false,
27454            false,
27455            true,
27456            None,
27457            false,
27458            false,
27459            false,
27460            false,
27461            false,
27462            false,
27463        )
27464        .unwrap();
27465        let nested = dir.path().join("src/alpha/nested");
27466        std::fs::create_dir_all(&nested).unwrap();
27467
27468        let result = cmd_explain(
27469            "alpha_main",
27470            &nested,
27471            None,
27472            15,
27473            false,
27474            false,
27475            false,
27476            false,
27477            false,
27478            false,
27479            false,
27480            false,
27481        );
27482
27483        assert!(result.is_ok());
27484    }
27485
27486    #[test]
27487    fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27488        let dir = setup_graph_index();
27489        let db_path = dir.path().join(".tsift/index.db");
27490        let _lock = hold_rollback_journal_lock(&db_path);
27491
27492        let result = cmd_explain(
27493            "main",
27494            dir.path(),
27495            None,
27496            15,
27497            false,
27498            false,
27499            false,
27500            false,
27501            false,
27502            false,
27503            false,
27504            false,
27505        );
27506
27507        assert!(result.is_ok());
27508    }
27509
27510    // --- community detection ---
27511
27512    #[test]
27513    fn community_detection_groups_related() {
27514        let dir = setup_graph_index();
27515        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27516        let edges = db.all_edges().unwrap();
27517        let result = graph::detect_communities(&edges);
27518        assert!(result.node_count > 0);
27519        assert!(!result.communities.is_empty());
27520    }
27521
27522    #[test]
27523    fn community_cmd_autoindexes_missing_index_by_default() {
27524        let dir = tempfile::tempdir().unwrap();
27525        let result = cmd_communities(
27526            dir.path(),
27527            None,
27528            2,
27529            10,
27530            false,
27531            false,
27532            false,
27533            false,
27534            false,
27535            false,
27536            TagpathSearchOpts::default(),
27537        );
27538
27539        assert!(result.is_ok());
27540        assert!(dir.path().join(".tsift/index.db").exists());
27541    }
27542
27543    // --- path ---
27544
27545    #[test]
27546    fn path_finds_connected_symbols() {
27547        let dir = setup_graph_index();
27548        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27549        let edges = db.all_edges().unwrap();
27550        let result = graph::shortest_path(&edges, "main", "helper");
27551        assert!(result.is_some());
27552        let path = result.unwrap();
27553        assert_eq!(path.hops, 1);
27554    }
27555
27556    #[test]
27557    fn path_returns_none_for_unknown() {
27558        let dir = setup_graph_index();
27559        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27560        let edges = db.all_edges().unwrap();
27561        assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
27562    }
27563
27564    #[test]
27565    fn path_cmd_autoindexes_missing_index_by_default() {
27566        let dir = tempfile::tempdir().unwrap();
27567        let result = cmd_path(
27568            "a",
27569            "b",
27570            dir.path(),
27571            None,
27572            false,
27573            false,
27574            false,
27575            false,
27576            false,
27577            TagpathSearchOpts::default(),
27578        );
27579
27580        assert!(result.is_ok());
27581        assert!(dir.path().join(".tsift/index.db").exists());
27582    }
27583
27584    // --- explain ---
27585
27586    #[test]
27587    fn explain_shows_symbol_info() {
27588        let dir = setup_graph_index();
27589        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27590        let symbols = db.symbol_info("main").unwrap();
27591        assert!(!symbols.is_empty());
27592        assert_eq!(symbols[0].name, "main");
27593        assert_eq!(symbols[0].kind, "function");
27594    }
27595
27596    #[test]
27597    fn explain_cmd_autoindexes_missing_index_by_default() {
27598        let dir = tempfile::tempdir().unwrap();
27599        let result = cmd_explain(
27600            "main",
27601            dir.path(),
27602            None,
27603            15,
27604            false,
27605            false,
27606            false,
27607            false,
27608            false,
27609            false,
27610            false,
27611            false,
27612        );
27613
27614        assert!(result.is_ok());
27615        assert!(dir.path().join(".tsift/index.db").exists());
27616    }
27617
27618    fn hold_write_lock(db_path: &std::path::Path) -> Connection {
27619        let conn = Connection::open(db_path).unwrap();
27620        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
27621        conn
27622    }
27623
27624    fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
27625        use fs4::fs_std::FileExt;
27626        use std::io::Write;
27627
27628        let mut file = std::fs::OpenOptions::new()
27629            .read(true)
27630            .write(true)
27631            .create(true)
27632            .truncate(false)
27633            .open(lock_path)
27634            .unwrap();
27635        assert!(file.try_lock_exclusive().unwrap());
27636        writeln!(file, "{}", std::process::id()).unwrap();
27637        file
27638    }
27639
27640    fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
27641        let conn = Connection::open(db_path).unwrap();
27642        conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
27643            .unwrap();
27644        std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
27645        conn
27646    }
27647
27648    fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
27649        let conn = Connection::open(db_path).unwrap();
27650        conn.execute_batch(
27651            "PRAGMA journal_mode=WAL;
27652             PRAGMA wal_autocheckpoint=0;
27653             CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
27654             INSERT INTO wal_lock_probe DEFAULT VALUES;
27655             PRAGMA locking_mode=EXCLUSIVE;
27656             BEGIN EXCLUSIVE;",
27657        )
27658        .unwrap();
27659        assert!(substrate::wal_sidecar_path(db_path).exists());
27660        conn
27661    }
27662
27663    #[test]
27664    fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
27665        let dir = setup_graph_index();
27666        let db_path = dir.path().join(".tsift/index.db");
27667        let _lock = hold_wal_database_lock(&db_path);
27668
27669        let err = cmd_index(
27670            dir.path(),
27671            false,
27672            false,
27673            false,
27674            false,
27675            false,
27676            false,
27677            None,
27678            false,
27679            false,
27680            false,
27681            false,
27682            false,
27683            false,
27684        )
27685        .unwrap_err();
27686
27687        let msg = err.to_string();
27688        assert!(msg.contains("indexing"));
27689        assert!(msg.contains("lock diagnostics:"));
27690        assert!(msg.contains("lock: absent"));
27691        assert!(msg.contains("wal: present") || msg.contains("shm: present"));
27692        assert!(msg.contains("wedged writer holding live WAL sidecars"));
27693        assert!(msg.contains("snapshot fallback"));
27694    }
27695
27696    #[test]
27697    fn search_cmd_succeeds_while_writer_lock_is_held() {
27698        let dir = setup_graph_index();
27699        let db_path = dir.path().join(".tsift/index.db");
27700        let _lock = hold_write_lock(&db_path);
27701
27702        let result = cmd_search(
27703            "main".to_string(),
27704            Some(dir.path().to_path_buf()),
27705            5,
27706            Some("lexical".to_string()),
27707            None,
27708            false,
27709            false,
27710            false,
27711            0,
27712            true,
27713            false,
27714            false,
27715            false,
27716            false,
27717            false,
27718            false,
27719        );
27720
27721        assert!(result.is_ok());
27722    }
27723
27724    #[test]
27725    fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
27726        let dir = setup_graph_index();
27727        let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
27728
27729        let result = cmd_search(
27730            "main".to_string(),
27731            Some(dir.path().to_path_buf()),
27732            5,
27733            Some("lexical".to_string()),
27734            None,
27735            false,
27736            false,
27737            false,
27738            0,
27739            true,
27740            false,
27741            false,
27742            false,
27743            false,
27744            false,
27745            false,
27746        );
27747
27748        assert!(result.is_ok());
27749    }
27750
27751    #[test]
27752    fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
27753        let dir = setup_graph_index();
27754        let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
27755
27756        let result = cmd_search(
27757            "main".to_string(),
27758            Some(dir.path().to_path_buf()),
27759            5,
27760            Some("lexical".to_string()),
27761            None,
27762            false,
27763            false,
27764            false,
27765            0,
27766            true,
27767            false,
27768            false,
27769            false,
27770            false,
27771            false,
27772            false,
27773        );
27774
27775        assert!(result.is_ok());
27776    }
27777
27778    #[test]
27779    fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
27780        let dir = setup_graph_index();
27781        std::thread::sleep(std::time::Duration::from_millis(50));
27782        std::fs::write(
27783            dir.path().join("main.rs"),
27784            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27785        )
27786        .unwrap();
27787
27788        let err = cmd_search(
27789            "helper".to_string(),
27790            Some(dir.path().to_path_buf()),
27791            5,
27792            Some("lexical".to_string()),
27793            None,
27794            false,
27795            false,
27796            false,
27797            0,
27798            false,
27799            false,
27800            false,
27801            false,
27802            false,
27803            false,
27804            false,
27805        )
27806        .unwrap_err();
27807
27808        assert!(err.to_string().contains("search aborted"));
27809        assert!(err.to_string().contains("index is stale"));
27810        assert!(err.to_string().contains("--no-autoindex"));
27811    }
27812
27813    #[test]
27814    fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
27815        let dir = setup_graph_index();
27816        std::thread::sleep(std::time::Duration::from_millis(50));
27817        std::fs::write(
27818            dir.path().join("main.rs"),
27819            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27820        )
27821        .unwrap();
27822        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27823
27824        let err = cmd_search(
27825            "helper".to_string(),
27826            Some(dir.path().to_path_buf()),
27827            5,
27828            Some("lexical".to_string()),
27829            None,
27830            false,
27831            false,
27832            false,
27833            0,
27834            false,
27835            false,
27836            false,
27837            false,
27838            false,
27839            false,
27840            false,
27841        )
27842        .unwrap_err();
27843
27844        assert!(err.to_string().contains("search aborted"));
27845        assert!(err.to_string().contains("index is stale"));
27846        assert!(!err.to_string().contains("database is locked"));
27847    }
27848
27849    #[test]
27850    fn search_cmd_autoindexes_stale_index_by_default() {
27851        let dir = setup_graph_index();
27852        std::thread::sleep(std::time::Duration::from_millis(50));
27853        std::fs::write(
27854            dir.path().join("main.rs"),
27855            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27856        )
27857        .unwrap();
27858
27859        let result = cmd_search(
27860            "helper".to_string(),
27861            Some(dir.path().to_path_buf()),
27862            5,
27863            Some("lexical".to_string()),
27864            None,
27865            false,
27866            false,
27867            true,
27868            0,
27869            false,
27870            false,
27871            false,
27872            false,
27873            false,
27874            false,
27875            false,
27876        );
27877
27878        assert!(result.is_ok());
27879
27880        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27881        let summary = db.compute_changes(dir.path()).unwrap();
27882        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27883    }
27884
27885    #[test]
27886    fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
27887        let dir = setup_graph_index();
27888        std::thread::sleep(std::time::Duration::from_millis(50));
27889        std::fs::write(
27890            dir.path().join("main.rs"),
27891            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27892        )
27893        .unwrap();
27894        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27895
27896        let result = cmd_search(
27897            "helper".to_string(),
27898            Some(dir.path().to_path_buf()),
27899            5,
27900            Some("lexical".to_string()),
27901            None,
27902            false,
27903            false,
27904            true,
27905            0,
27906            false,
27907            false,
27908            false,
27909            false,
27910            false,
27911            false,
27912            false,
27913        );
27914
27915        assert!(result.is_ok());
27916
27917        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27918        let summary = db.compute_changes(dir.path()).unwrap();
27919        assert_eq!(summary.modified, 1);
27920    }
27921
27922    #[test]
27923    fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
27924        let dir = setup_graph_index();
27925        std::thread::sleep(std::time::Duration::from_millis(50));
27926        std::fs::write(
27927            dir.path().join("main.rs"),
27928            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27929        )
27930        .unwrap();
27931        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27932
27933        let err = cmd_search(
27934            "helper".to_string(),
27935            Some(dir.path().to_path_buf()),
27936            5,
27937            Some("lexical".to_string()),
27938            None,
27939            false,
27940            false,
27941            true,
27942            0,
27943            false,
27944            false,
27945            false,
27946            false,
27947            false,
27948            false,
27949            false,
27950        )
27951        .unwrap_err();
27952
27953        let msg = err.to_string();
27954        assert!(msg.contains("autoindexing index"));
27955        assert!(msg.contains("lock diagnostics:"));
27956        assert!(msg.contains("journal: present"));
27957        assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
27958    }
27959
27960    #[test]
27961    fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
27962        let dir = setup_graph_index();
27963        let nested = dir.path().join("src/nested");
27964        std::fs::create_dir_all(&nested).unwrap();
27965
27966        let result = cmd_search(
27967            "helper".to_string(),
27968            Some(nested.clone()),
27969            5,
27970            Some("lexical".to_string()),
27971            None,
27972            false,
27973            false,
27974            true,
27975            0,
27976            false,
27977            false,
27978            false,
27979            false,
27980            false,
27981            false,
27982            false,
27983        );
27984
27985        assert!(result.is_ok());
27986        assert!(!nested.join(".tsift/index.db").exists());
27987    }
27988
27989    #[test]
27990    fn exact_search_returns_literal_matches() {
27991        let dir = tempfile::tempdir().unwrap();
27992        std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
27993
27994        let response = run_exact_search_with_timeout(
27995            std::slice::from_ref(&dir.path().to_path_buf()),
27996            "claudescore-3",
27997            5,
27998            0,
27999        )
28000        .unwrap();
28001
28002        assert_eq!(response.strategy, "exact");
28003        assert_eq!(response.hits.len(), 1);
28004        assert!(response.hits[0].path.ends_with("notes.txt"));
28005        assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
28006        assert!(response.hits[0].snippet.contains("claudescore-3"));
28007    }
28008
28009    #[test]
28010    fn exact_search_skips_stale_index_precheck() {
28011        let dir = setup_graph_index();
28012        std::thread::sleep(std::time::Duration::from_millis(50));
28013        std::fs::write(
28014            dir.path().join("main.rs"),
28015            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
28016        )
28017        .unwrap();
28018
28019        let result = cmd_search(
28020            "println!(\"updated\")".to_string(),
28021            Some(dir.path().to_path_buf()),
28022            5,
28023            Some("exact".to_string()),
28024            None,
28025            false,
28026            false,
28027            false,
28028            0,
28029            false,
28030            false,
28031            false,
28032            false,
28033            false,
28034            false,
28035            false,
28036        );
28037
28038        assert!(result.is_ok());
28039    }
28040
28041    #[test]
28042    fn workspace_exact_search_does_not_require_shared_root_index() {
28043        let dir = setup_workspace();
28044        cmd_index(
28045            dir.path(),
28046            false,
28047            false,
28048            false,
28049            false,
28050            false,
28051            true,
28052            None,
28053            false,
28054            false,
28055            false,
28056            false,
28057            false,
28058            false,
28059        )
28060        .unwrap();
28061
28062        let result = cmd_search(
28063            "alpha_helper".to_string(),
28064            Some(dir.path().to_path_buf()),
28065            5,
28066            Some("exact".to_string()),
28067            None,
28068            false,
28069            false,
28070            false,
28071            0,
28072            false,
28073            false,
28074            false,
28075            false,
28076            false,
28077            false,
28078            false,
28079        );
28080
28081        assert!(result.is_ok());
28082        assert!(!dir.path().join(".tsift/index.db").exists());
28083    }
28084
28085    #[test]
28086    fn identifier_like_query_prefers_exact_search() {
28087        assert!(query_prefers_exact_search("claudescore-3"));
28088        assert!(query_prefers_exact_search("alpha_helper"));
28089        assert!(query_prefers_exact_search("src/main.rs"));
28090        assert!(query_prefers_exact_search("crate::module"));
28091        assert!(!query_prefers_exact_search("authenticate"));
28092        assert!(!query_prefers_exact_search("fn main"));
28093        assert!(!query_prefers_exact_search("."));
28094    }
28095
28096    #[test]
28097    fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
28098        assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
28099        assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
28100        assert_eq!(
28101            resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
28102            "hybrid"
28103        );
28104    }
28105
28106    #[test]
28107    fn workspace_identifier_like_search_auto_uses_exact_backend() {
28108        let dir = setup_workspace();
28109        cmd_index(
28110            dir.path(),
28111            false,
28112            false,
28113            false,
28114            false,
28115            false,
28116            true,
28117            None,
28118            false,
28119            false,
28120            false,
28121            false,
28122            false,
28123            false,
28124        )
28125        .unwrap();
28126
28127        let result = cmd_search(
28128            "alpha_helper".to_string(),
28129            Some(dir.path().to_path_buf()),
28130            5,
28131            None,
28132            None,
28133            false,
28134            false,
28135            false,
28136            0,
28137            false,
28138            false,
28139            false,
28140            false,
28141            false,
28142            false,
28143            false,
28144        );
28145
28146        assert!(result.is_ok());
28147        assert!(!dir.path().join(".tsift/index.db").exists());
28148    }
28149
28150    #[test]
28151    fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
28152        let dir = setup_graph_index();
28153        let nested = dir.path().join("src/nested");
28154        std::fs::create_dir_all(&nested).unwrap();
28155        std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
28156
28157        let result = cmd_index(
28158            &nested, false, false, false, false, false, false, None, false, false, false, false,
28159            false, false,
28160        );
28161
28162        assert!(result.is_ok());
28163        assert!(dir.path().join(".tsift/index.db").exists());
28164        assert!(!nested.join(".tsift/index.db").exists());
28165    }
28166
28167    #[test]
28168    fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
28169        let dir = setup_workspace();
28170        let nested = dir.path().join("docs/nested");
28171        std::fs::create_dir_all(&nested).unwrap();
28172
28173        let result = cmd_index(
28174            &nested, false, false, false, false, false, true, None, false, false, false, false,
28175            false, false,
28176        );
28177
28178        let cfg = config::Config::load(dir.path()).unwrap();
28179
28180        assert!(result.is_ok());
28181        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28182        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28183    }
28184
28185    #[test]
28186    fn status_cmd_autoindexes_missing_workspace_scopes() {
28187        let dir = setup_workspace();
28188        let cfg = config::Config::load(dir.path()).unwrap();
28189        let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
28190        let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
28191        let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
28192        alpha_db.apply_changes(&alpha.source_root).unwrap();
28193
28194        let beta_db_path = cfg.db_path_for(dir.path(), "beta");
28195        assert!(!beta_db_path.exists());
28196
28197        cmd_status(
28198            dir.path(),
28199            StatusCommandOptions {
28200                fix: false,
28201                no_fix: false,
28202                json_output: true,
28203                compact: false,
28204                pretty: false,
28205                terse: false,
28206                schema: false,
28207            },
28208        )
28209        .unwrap();
28210
28211        assert!(beta_db_path.exists());
28212        let report = status::check_status(dir.path()).unwrap();
28213        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28214    }
28215
28216    #[test]
28217    fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
28218        let dir = setup_workspace();
28219        let cfg = config::Config::load(dir.path()).unwrap();
28220
28221        cmd_status(
28222            dir.path(),
28223            StatusCommandOptions {
28224                fix: false,
28225                no_fix: false,
28226                json_output: true,
28227                compact: false,
28228                pretty: false,
28229                terse: false,
28230                schema: false,
28231            },
28232        )
28233        .unwrap();
28234
28235        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28236        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28237        let report = status::check_status(dir.path()).unwrap();
28238        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28239    }
28240
28241    #[test]
28242    fn status_fix_targets_only_stale_workspace_scopes() {
28243        let dir = setup_workspace();
28244        let cfg = config::Config::load(dir.path()).unwrap();
28245        for scope_id in ["alpha", "beta"] {
28246            let scope = config::Config::resolve_submodule(dir.path(), scope_id).unwrap();
28247            let db = index::IndexDb::open(&cfg.db_path_for(dir.path(), scope_id)).unwrap();
28248            db.apply_changes(&scope.source_root).unwrap();
28249        }
28250
28251        std::thread::sleep(std::time::Duration::from_millis(50));
28252        std::fs::write(
28253            dir.path().join("src/alpha/lib.rs"),
28254            "fn alpha_helper() { println!(\"updated\"); }\n",
28255        )
28256        .unwrap();
28257
28258        let report = status::check_status(dir.path()).unwrap();
28259        let scope_ids = status_workspace_scope_ids_needing_fix(&report);
28260        assert_eq!(scope_ids, std::collections::HashSet::from(["alpha"]));
28261
28262        cmd_status(
28263            dir.path(),
28264            StatusCommandOptions {
28265                fix: false,
28266                no_fix: false,
28267                json_output: true,
28268                compact: false,
28269                pretty: false,
28270                terse: false,
28271                schema: false,
28272            },
28273        )
28274        .unwrap();
28275
28276        let report = status::check_status(dir.path()).unwrap();
28277        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28278    }
28279
28280    #[test]
28281    fn status_cmd_fix_refreshes_stale_index() {
28282        let dir = setup_graph_index();
28283        std::thread::sleep(std::time::Duration::from_millis(50));
28284        std::fs::write(
28285            dir.path().join("main.rs"),
28286            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28287        )
28288        .unwrap();
28289
28290        let report = status::check_status(dir.path()).unwrap();
28291        assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
28292
28293        cmd_status(
28294            dir.path(),
28295            StatusCommandOptions {
28296                fix: false,
28297                no_fix: false,
28298                json_output: true,
28299                compact: false,
28300                pretty: false,
28301                terse: false,
28302                schema: false,
28303            },
28304        )
28305        .unwrap();
28306
28307        let report = status::check_status(dir.path()).unwrap();
28308        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28309    }
28310
28311    #[test]
28312    fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
28313        let dir = setup_graph_index();
28314        let db_path = dir.path().join(".tsift/index.db");
28315        let _lock = hold_wal_database_lock(&db_path);
28316
28317        cmd_status(
28318            dir.path(),
28319            StatusCommandOptions {
28320                fix: false,
28321                no_fix: false,
28322                json_output: true,
28323                compact: false,
28324                pretty: false,
28325                terse: false,
28326                schema: false,
28327            },
28328        )
28329        .unwrap();
28330
28331        let report = status::check_status(dir.path()).unwrap();
28332        assert!(matches!(
28333            report.index,
28334            status::IndexStatus::Fresh {
28335                recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
28336                ..
28337            }
28338        ));
28339        let locks = status::check_locks(dir.path(), None, None).unwrap();
28340        assert!(matches!(
28341            locks.writer_lock,
28342            status::WriterLockStatus::Absent { .. }
28343        ));
28344        assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
28345        assert!(
28346            locks
28347                .recommended_action
28348                .contains("wedged writer holding live WAL sidecars")
28349        );
28350    }
28351
28352    #[test]
28353    fn locks_report_uses_ancestor_project_root_for_nested_paths() {
28354        let dir = setup_graph_index();
28355        let nested = dir.path().join("src/nested");
28356        std::fs::create_dir_all(&nested).unwrap();
28357
28358        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28359        let report = status::check_locks(&root, Some(&nested), None).unwrap();
28360
28361        assert_eq!(report.source_root, dir.path());
28362        assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
28363    }
28364
28365    #[test]
28366    fn workspace_locks_report_infers_scope_from_nested_path() {
28367        let dir = setup_workspace();
28368        cmd_index(
28369            dir.path(),
28370            false,
28371            false,
28372            false,
28373            false,
28374            false,
28375            true,
28376            None,
28377            false,
28378            false,
28379            false,
28380            false,
28381            false,
28382            false,
28383        )
28384        .unwrap();
28385        let nested = dir.path().join("src/alpha/nested");
28386        std::fs::create_dir_all(&nested).unwrap();
28387
28388        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28389        let report = status::check_locks(&root, Some(&nested), None).unwrap();
28390        let cfg = config::Config::load(dir.path()).unwrap();
28391
28392        assert_eq!(report.label, "submodule `alpha` index");
28393        assert_eq!(report.source_root, dir.path().join("src/alpha"));
28394        assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
28395        assert_eq!(
28396            report.reindex_command,
28397            format!("tsift index --submodule alpha {}", dir.path().display())
28398        );
28399    }
28400
28401    #[test]
28402    fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
28403        let dir = setup_workspace();
28404        cmd_index(
28405            dir.path(),
28406            false,
28407            false,
28408            false,
28409            false,
28410            false,
28411            true,
28412            None,
28413            false,
28414            false,
28415            false,
28416            false,
28417            false,
28418            false,
28419        )
28420        .unwrap();
28421
28422        let alpha = dir.path().join("src/alpha/lib.rs");
28423        std::thread::sleep(std::time::Duration::from_millis(50));
28424        std::fs::write(
28425            &alpha,
28426            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28427        )
28428        .unwrap();
28429
28430        let result = cmd_search(
28431            "alpha_helper".to_string(),
28432            Some(dir.path().to_path_buf()),
28433            5,
28434            Some("lexical".to_string()),
28435            Some("alpha".to_string()),
28436            false,
28437            false,
28438            true,
28439            0,
28440            false,
28441            false,
28442            false,
28443            false,
28444            false,
28445            false,
28446            false,
28447        );
28448
28449        assert!(result.is_ok());
28450
28451        let cfg = config::Config::load(dir.path()).unwrap();
28452        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28453        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28454        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28455    }
28456
28457    #[test]
28458    fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28459        let dir = setup_workspace();
28460        cmd_index(
28461            dir.path(),
28462            false,
28463            false,
28464            false,
28465            false,
28466            false,
28467            true,
28468            None,
28469            false,
28470            false,
28471            false,
28472            false,
28473            false,
28474            false,
28475        )
28476        .unwrap();
28477
28478        let alpha = dir.path().join("src/alpha/lib.rs");
28479        std::thread::sleep(std::time::Duration::from_millis(50));
28480        std::fs::write(
28481            &alpha,
28482            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28483        )
28484        .unwrap();
28485
28486        let cfg = config::Config::load(dir.path()).unwrap();
28487        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28488
28489        let err = cmd_search(
28490            "alpha_helper".to_string(),
28491            Some(dir.path().to_path_buf()),
28492            5,
28493            Some("lexical".to_string()),
28494            Some("alpha".to_string()),
28495            false,
28496            false,
28497            false,
28498            0,
28499            false,
28500            false,
28501            false,
28502            false,
28503            false,
28504            false,
28505            false,
28506        )
28507        .unwrap_err();
28508
28509        assert!(err.to_string().contains("search aborted"));
28510        assert!(err.to_string().contains("submodule `alpha` index"));
28511        assert!(!err.to_string().contains("database is locked"));
28512    }
28513
28514    #[test]
28515    fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
28516        let dir = setup_workspace();
28517        cmd_index(
28518            dir.path(),
28519            false,
28520            false,
28521            false,
28522            false,
28523            false,
28524            true,
28525            None,
28526            false,
28527            false,
28528            false,
28529            false,
28530            false,
28531            false,
28532        )
28533        .unwrap();
28534
28535        let alpha = dir.path().join("src/alpha/lib.rs");
28536        std::thread::sleep(std::time::Duration::from_millis(50));
28537        std::fs::write(
28538            &alpha,
28539            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28540        )
28541        .unwrap();
28542
28543        let result = cmd_search(
28544            "alpha_helper".to_string(),
28545            Some(dir.path().to_path_buf()),
28546            5,
28547            Some("lexical".to_string()),
28548            None,
28549            true,
28550            false,
28551            true,
28552            0,
28553            false,
28554            false,
28555            false,
28556            false,
28557            false,
28558            false,
28559            false,
28560        );
28561
28562        assert!(result.is_ok());
28563
28564        let cfg = config::Config::load(dir.path()).unwrap();
28565        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28566        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28567        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28568    }
28569
28570    #[test]
28571    fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28572        let dir = setup_workspace();
28573        cmd_index(
28574            dir.path(),
28575            false,
28576            false,
28577            false,
28578            false,
28579            false,
28580            true,
28581            None,
28582            false,
28583            false,
28584            false,
28585            false,
28586            false,
28587            false,
28588        )
28589        .unwrap();
28590
28591        let alpha = dir.path().join("src/alpha/lib.rs");
28592        std::thread::sleep(std::time::Duration::from_millis(50));
28593        std::fs::write(
28594            &alpha,
28595            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28596        )
28597        .unwrap();
28598
28599        let cfg = config::Config::load(dir.path()).unwrap();
28600        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28601
28602        let err = cmd_search(
28603            "alpha_helper".to_string(),
28604            Some(dir.path().to_path_buf()),
28605            5,
28606            Some("lexical".to_string()),
28607            None,
28608            true,
28609            false,
28610            false,
28611            30,
28612            false,
28613            false,
28614            false,
28615            false,
28616            false,
28617            false,
28618            false,
28619        )
28620        .unwrap_err();
28621
28622        assert!(err.to_string().contains("stale"));
28623        assert!(err.to_string().contains("submodule `alpha` index"));
28624        assert!(!err.to_string().contains("database is locked"));
28625    }
28626
28627    #[test]
28628    fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
28629        let dir = setup_workspace();
28630        cmd_index(
28631            dir.path(),
28632            false,
28633            false,
28634            false,
28635            false,
28636            false,
28637            true,
28638            None,
28639            false,
28640            false,
28641            false,
28642            false,
28643            false,
28644            false,
28645        )
28646        .unwrap();
28647
28648        let err = cmd_search(
28649            "alpha_helper".to_string(),
28650            Some(dir.path().to_path_buf()),
28651            5,
28652            Some("lexical".to_string()),
28653            None,
28654            false,
28655            false,
28656            true,
28657            0,
28658            false,
28659            false,
28660            false,
28661            false,
28662            false,
28663            false,
28664            false,
28665        )
28666        .unwrap_err();
28667
28668        assert_workspace_search_requires_explicit_target(err);
28669        assert!(!dir.path().join(".tsift/index.db").exists());
28670    }
28671
28672    #[test]
28673    fn workspace_search_cmd_infers_scope_from_nested_path() {
28674        let dir = setup_workspace();
28675        cmd_index(
28676            dir.path(),
28677            false,
28678            false,
28679            false,
28680            false,
28681            false,
28682            true,
28683            None,
28684            false,
28685            false,
28686            false,
28687            false,
28688            false,
28689            false,
28690        )
28691        .unwrap();
28692        let nested = dir.path().join("src/alpha/nested");
28693        std::fs::create_dir_all(&nested).unwrap();
28694
28695        let result = cmd_search(
28696            "alpha_helper".to_string(),
28697            Some(nested),
28698            5,
28699            Some("lexical".to_string()),
28700            None,
28701            false,
28702            false,
28703            false,
28704            0,
28705            false,
28706            false,
28707            false,
28708            false,
28709            false,
28710            false,
28711            false,
28712        );
28713
28714        assert!(result.is_ok());
28715    }
28716
28717    #[test]
28718    fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
28719        let dir = setup_workspace_with_duplicate_leaf_names();
28720        cmd_index(
28721            dir.path(),
28722            false,
28723            false,
28724            false,
28725            false,
28726            false,
28727            true,
28728            None,
28729            false,
28730            false,
28731            false,
28732            false,
28733            false,
28734            false,
28735        )
28736        .unwrap();
28737        let nested = dir.path().join("vendor/foo/nested");
28738        std::fs::create_dir_all(&nested).unwrap();
28739
28740        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28741        let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
28742        let cfg = config::Config::load(dir.path()).unwrap();
28743
28744        assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
28745    }
28746
28747    #[test]
28748    fn graph_cmd_succeeds_while_writer_lock_is_held() {
28749        let dir = setup_graph_index();
28750        let db_path = dir.path().join(".tsift/index.db");
28751        let _lock = hold_write_lock(&db_path);
28752
28753        let result = cmd_graph(
28754            "main",
28755            dir.path(),
28756            false,
28757            false,
28758            None,
28759            20,
28760            false,
28761            true,
28762            false,
28763            false,
28764            false,
28765            false,
28766            false,
28767            TagpathSearchOpts::default(),
28768        );
28769
28770        assert!(result.is_ok());
28771    }
28772
28773    #[test]
28774    fn graph_cmd_autoindexes_stale_index_by_default() {
28775        let dir = setup_graph_index();
28776        std::thread::sleep(std::time::Duration::from_millis(50));
28777        std::fs::write(
28778            dir.path().join("main.rs"),
28779            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28780        )
28781        .unwrap();
28782
28783        let result = cmd_graph(
28784            "helper",
28785            dir.path(),
28786            true,
28787            false,
28788            None,
28789            20,
28790            false,
28791            true,
28792            false,
28793            false,
28794            false,
28795            false,
28796            false,
28797            TagpathSearchOpts::default(),
28798        );
28799
28800        assert!(result.is_ok());
28801        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
28802        let summary = db.compute_changes(dir.path()).unwrap();
28803        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28804    }
28805
28806    #[test]
28807    fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28808        let dir = setup_graph_index();
28809        let db_path = dir.path().join(".tsift/index.db");
28810        let _lock = hold_rollback_journal_lock(&db_path);
28811
28812        let result = cmd_graph(
28813            "main",
28814            dir.path(),
28815            false,
28816            false,
28817            None,
28818            20,
28819            false,
28820            true,
28821            false,
28822            false,
28823            false,
28824            false,
28825            false,
28826            TagpathSearchOpts::default(),
28827        );
28828
28829        assert!(result.is_ok());
28830    }
28831
28832    #[test]
28833    fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
28834        let dir = setup_graph_index();
28835        let nested = dir.path().join("src/nested");
28836        std::fs::create_dir_all(&nested).unwrap();
28837
28838        let result = cmd_graph(
28839            "helper",
28840            &nested,
28841            true,
28842            false,
28843            None,
28844            20,
28845            false,
28846            false,
28847            false,
28848            false,
28849            false,
28850            false,
28851            false,
28852            TagpathSearchOpts::default(),
28853        );
28854
28855        assert!(result.is_ok());
28856    }
28857
28858    #[test]
28859    fn communities_cmd_succeeds_while_writer_lock_is_held() {
28860        let dir = setup_graph_index();
28861        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
28862
28863        let result = cmd_communities(
28864            dir.path(),
28865            None,
28866            1,
28867            10,
28868            false,
28869            false,
28870            false,
28871            false,
28872            false,
28873            false,
28874            TagpathSearchOpts::default(),
28875        );
28876
28877        assert!(result.is_ok());
28878    }
28879
28880    #[test]
28881    fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28882        let dir = setup_graph_index();
28883        let db_path = dir.path().join(".tsift/index.db");
28884        let _lock = hold_rollback_journal_lock(&db_path);
28885
28886        let result = cmd_communities(
28887            dir.path(),
28888            None,
28889            1,
28890            10,
28891            false,
28892            false,
28893            false,
28894            false,
28895            false,
28896            false,
28897            TagpathSearchOpts::default(),
28898        );
28899
28900        assert!(result.is_ok());
28901    }
28902
28903    #[test]
28904    fn lint_finds_entities_from_project_root_index_db() {
28905        let dir = tempfile::tempdir().unwrap();
28906        std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
28907        std::fs::write(
28908            dir.path().join("README.md"),
28909            "alpha_helper should be backticked.\n",
28910        )
28911        .unwrap();
28912        cmd_index(
28913            dir.path(),
28914            false,
28915            false,
28916            false,
28917            false,
28918            false,
28919            false,
28920            None,
28921            false,
28922            false,
28923            false,
28924            false,
28925            false,
28926            false,
28927        )
28928        .unwrap();
28929
28930        let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
28931            .unwrap()
28932            .unwrap();
28933        let entities = lint::collect_entities_from_index_path(&root).unwrap();
28934        let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
28935
28936        assert!(
28937            result
28938                .annotations
28939                .iter()
28940                .any(|ann| ann.text == "alpha_helper")
28941        );
28942    }
28943
28944    // --- search timeout ---
28945
28946    #[test]
28947    fn search_direct_runs_ok() {
28948        let dir = tempfile::tempdir().unwrap();
28949        let search_dir = dir.path().to_path_buf();
28950        let cache_dir = search_dir.join(".tsift/search-cache");
28951        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28952        let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical", None);
28953        assert!(result.is_ok(), "direct search should succeed");
28954        assert!(
28955            cache_dir.exists(),
28956            "search should create the configured cache dir"
28957        );
28958    }
28959
28960    #[test]
28961    fn search_timeout_zero_disables_timeout() {
28962        let dir = tempfile::tempdir().unwrap();
28963        let search_dir = dir.path().to_path_buf();
28964        let cache_dir = search_dir.join(".tsift/search-cache");
28965        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28966        let result =
28967            run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[], None);
28968        assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
28969        assert!(
28970            cache_dir.exists(),
28971            "timeout=0 should keep using the stable search cache dir"
28972        );
28973    }
28974
28975    #[test]
28976    fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
28977        let dir = tempfile::tempdir().unwrap();
28978        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
28979        cmd_index(
28980            dir.path(),
28981            false,
28982            false,
28983            false,
28984            false,
28985            false,
28986            false,
28987            None,
28988            false,
28989            false,
28990            false,
28991            false,
28992            false,
28993            false,
28994        )
28995        .unwrap();
28996        let db_path = dir.path().join(".tsift/index.db");
28997        std::fs::remove_file(&db_path).unwrap();
28998        let search_target = SearchIndexTarget {
28999            label: "index".to_string(),
29000            db_path,
29001            source_root: dir.path().to_path_buf(),
29002            scope_name: None,
29003            reindex_cmd: format!("tsift index {}", dir.path().display()),
29004        };
29005
29006        let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
29007
29008        assert!(message.contains("timed out after 1s"));
29009        assert!(message.contains("index is missing"));
29010        assert!(message.contains("Run `tsift index"));
29011        assert!(!message.contains("search root looks fresh"));
29012    }
29013
29014    #[test]
29015    fn search_worker_output_path_uses_json_suffix() {
29016        let path = next_search_worker_output_path();
29017        assert!(path.extension().is_some_and(|ext| ext == "json"));
29018    }
29019
29020    #[test]
29021    fn fts_search_flag_value_parses_falsy_escape_hatch() {
29022        // #015t Phase 4: FTS5 is the default; only falsy values force legacy.
29023        for falsy in ["0", "false", "FALSE", " no ", "Off"] {
29024            assert!(
29025                fts_flag_value_disabled(falsy),
29026                "{falsy:?} should force the legacy TokenIndex path"
29027            );
29028        }
29029        for keeps_default in ["", "1", "true", "yes", "on", "maybe"] {
29030            assert!(
29031                !fts_flag_value_disabled(keeps_default),
29032                "{keeps_default:?} should keep the FTS5 default"
29033            );
29034        }
29035    }
29036
29037    #[test]
29038    fn run_sift_search_defaults_to_fts_when_index_db_present() {
29039        // #015t Phase 4 cutover: with the flag unset and a root index.db present,
29040        // run_sift_search uses the FTS5 path by default (strategy "fts").
29041        let dir = tempfile::tempdir().unwrap();
29042        let root = dir.path();
29043        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29044        index::IndexDb::open(&root.join(".tsift/index.db"))
29045            .unwrap()
29046            .apply_changes(root)
29047            .unwrap();
29048        let cache_dir = root.join(".tsift/search-cache");
29049
29050        // Guard against an ambient escape-hatch env from a parallel test/shell.
29051        if fts_search_forced_off() {
29052            return;
29053        }
29054        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
29055        assert_eq!(response.strategy, "fts");
29056        assert!(response.hits.iter().any(|h| h.path.ends_with("alpha.rs")));
29057    }
29058
29059    #[test]
29060    fn run_sift_search_falls_back_to_lexical_without_index_db() {
29061        // No root index.db (e.g. un-indexed root reaching here directly): the
29062        // legacy TokenIndex/lexical path still serves the query.
29063        let dir = tempfile::tempdir().unwrap();
29064        let root = dir.path();
29065        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29066        let cache_dir = root.join(".tsift/search-cache");
29067
29068        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
29069        assert_eq!(response.strategy, "lexical");
29070    }
29071
29072    #[test]
29073    fn run_sift_search_honors_threaded_freshness_verdict() {
29074        // #015t Phase 4b: the caller's freshness verdict overrides the in-engine
29075        // inspect. Some(true) ⇒ FTS without re-walking; Some(false) ⇒ legacy path
29076        // even with a fresh index.db (the degraded-read-only live-results case).
29077        let dir = tempfile::tempdir().unwrap();
29078        let root = dir.path();
29079        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29080        index::IndexDb::open(&root.join(".tsift/index.db"))
29081            .unwrap()
29082            .apply_changes(root)
29083            .unwrap();
29084        let cache_dir = root.join(".tsift/search-cache");
29085
29086        if fts_search_forced_off() {
29087            return;
29088        }
29089        let fresh =
29090            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(true)).unwrap();
29091        assert_eq!(fresh.strategy, "fts");
29092
29093        let stale =
29094            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(false)).unwrap();
29095        assert_eq!(stale.strategy, "lexical");
29096    }
29097
29098    // --- index quiet mode ---
29099
29100    #[test]
29101    fn index_quiet_suppresses_file_list() {
29102        let dir = setup_graph_index();
29103        let result = cmd_index(
29104            dir.path(),
29105            false,
29106            true,
29107            false,
29108            false,
29109            true,
29110            false,
29111            None,
29112            false,
29113            false,
29114            false,
29115            false,
29116            false,
29117            false,
29118        );
29119        assert!(result.is_ok());
29120    }
29121
29122    #[test]
29123    fn index_exit_code_implies_quiet() {
29124        let dir = setup_graph_index();
29125        let result = cmd_index(
29126            dir.path(),
29127            false,
29128            true,
29129            false,
29130            false,
29131            false,
29132            false,
29133            None,
29134            false,
29135            false,
29136            false,
29137            false,
29138            false,
29139            false,
29140        );
29141        assert!(result.is_ok());
29142    }
29143
29144    #[test]
29145    fn index_quiet_json_omits_changes() {
29146        let dir = setup_graph_index();
29147        let result = cmd_index(
29148            dir.path(),
29149            false,
29150            true,
29151            false,
29152            false,
29153            true,
29154            false,
29155            None,
29156            true,
29157            false,
29158            false,
29159            false,
29160            false,
29161            false,
29162        );
29163        assert!(result.is_ok());
29164    }
29165
29166    #[test]
29167    fn cli_workflow_defaults_to_search_topic() {
29168        let cli = parse_cli(["tsift", "workflow"]);
29169        match cli.command {
29170            Some(Commands::Workflow { topic, json }) => {
29171                assert_eq!(topic, "search");
29172                assert!(!json);
29173            }
29174            _ => panic!("expected Workflow command"),
29175        }
29176    }
29177
29178    #[test]
29179    fn search_workflow_recipe_preserves_handles_across_expansions() {
29180        let recipe = workflow::search_workflow_recipe();
29181        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29182        assert_eq!(
29183            step_names,
29184            vec![
29185                "exact-anchor",
29186                "semantic-search",
29187                "explain-symbol",
29188                "summarize-selection",
29189                "digest-expansion"
29190            ]
29191        );
29192        assert!(
29193            recipe
29194                .handle_contract
29195                .iter()
29196                .any(|item| item.contains("originating command"))
29197        );
29198        assert!(
29199            recipe.steps[1]
29200                .preserves
29201                .iter()
29202                .any(|item| item.contains("sfam-*"))
29203        );
29204        assert!(
29205            recipe.steps[2]
29206                .preserves
29207                .iter()
29208                .any(|item| item.contains("ecall-*"))
29209        );
29210        assert!(
29211            recipe.steps[4]
29212                .preserves
29213                .iter()
29214                .any(|item| item.contains("artifact handles"))
29215        );
29216    }
29217
29218    #[test]
29219    fn kg_workflow_recipe_covers_extract_to_evidence() {
29220        let recipe = workflow::kg_workflow_recipe();
29221        assert_eq!(recipe.topic, "kg");
29222        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29223        assert_eq!(
29224            step_names,
29225            vec!["smoke-check", "extract", "status", "refresh", "evidence"]
29226        );
29227        // evidence uses --symbol (not a positional) and has no --budget flag
29228        let evidence = recipe.steps.last().unwrap();
29229        assert!(evidence.command.contains("kg evidence --symbol"));
29230        assert!(!evidence.command.contains("--budget"));
29231        // extract is the write step; reads should not re-extract
29232        assert!(
29233            recipe
29234                .handle_contract
29235                .iter()
29236                .any(|item| item.contains("Extract once"))
29237        );
29238    }
29239
29240    // --- JSON compact vs pretty ---
29241
29242    #[test]
29243    fn to_json_compact_default() {
29244        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29245        let compact = to_json(&val, false, false).unwrap();
29246        assert!(!compact.contains('\n'));
29247        assert!(
29248            compact.contains("\"a\":1")
29249                || compact.contains("\"a\": 1")
29250                || compact.contains("\"a\":")
29251        );
29252    }
29253
29254    #[test]
29255    fn to_json_pretty_indents() {
29256        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29257        let pretty = to_json(&val, true, false).unwrap();
29258        assert!(pretty.contains('\n'));
29259        assert!(pretty.contains("  "));
29260    }
29261
29262    #[test]
29263    fn to_json_compact_is_shorter() {
29264        let val =
29265            serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
29266        let compact = to_json(&val, false, false).unwrap();
29267        let pretty = to_json(&val, true, false).unwrap();
29268        assert!(compact.len() < pretty.len());
29269    }
29270
29271    #[test]
29272    fn terse_renames_keys() {
29273        let val =
29274            serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
29275        let result = to_json(&val, false, true).unwrap();
29276        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29277        assert!(parsed["_s"].is_object());
29278        let d = &parsed["d"];
29279        assert_eq!(d["cf"], "a.rs");
29280        assert_eq!(d["cn"], "main");
29281        assert_eq!(d["csl"], 10);
29282    }
29283
29284    #[test]
29285    fn terse_schema_only_includes_used_keys() {
29286        let val = serde_json::json!({"name": "test", "score": 0.5});
29287        let result = to_json(&val, false, true).unwrap();
29288        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29289        let schema = parsed["_s"].as_object().unwrap();
29290        assert_eq!(schema["n"], "name");
29291        assert_eq!(schema["sc"], "score");
29292        assert!(!schema.contains_key("cf"));
29293    }
29294
29295    #[test]
29296    fn terse_nested_arrays() {
29297        let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
29298        let result = to_json(&val, false, true).unwrap();
29299        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29300        let d = &parsed["d"];
29301        assert_eq!(d["crs"][0]["cn"], "a");
29302        assert_eq!(d["crs"][0]["cf"], "b.rs");
29303    }
29304
29305    #[test]
29306    fn terse_preserves_unknown_keys() {
29307        let val = serde_json::json!({"custom_field": "value", "name": "test"});
29308        let result = to_json(&val, false, true).unwrap();
29309        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29310        let d = &parsed["d"];
29311        assert_eq!(d["custom_field"], "value");
29312        assert_eq!(d["n"], "test");
29313    }
29314
29315    // --- ultra-terse ---
29316
29317    #[test]
29318    fn ultra_terse_strips_properties_from_graph_nodes() {
29319        let val = serde_json::json!({
29320            "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
29321        });
29322        let result = to_json_schema(&val, false, true, true, false).unwrap();
29323        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29324        let node = &parsed["d"]["nodes"][0];
29325        assert_eq!(node["id"], "fn:main");
29326        assert_eq!(node["k"], "fn");
29327        assert_eq!(node["n"], "main");
29328        assert!(node.get("properties").is_none());
29329    }
29330
29331    #[test]
29332    fn ultra_terse_strips_properties_from_graph_edges() {
29333        let val = serde_json::json!({
29334            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
29335        });
29336        let result = to_json_schema(&val, false, true, true, false).unwrap();
29337        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29338        let edge = &parsed["d"]["edges"][0];
29339        assert_eq!(edge["from_id"], "a");
29340        assert_eq!(edge["to_id"], "b");
29341        assert_eq!(edge["k"], "c");
29342        assert!(edge.get("properties").is_none());
29343    }
29344
29345    #[test]
29346    fn ultra_terse_abbreviates_edge_kinds() {
29347        let val = serde_json::json!({
29348            "edges": [
29349                {"from_id": "a", "to_id": "b", "kind": "defines"},
29350                {"from_id": "a", "to_id": "c", "kind": "contains"},
29351                {"from_id": "a", "to_id": "d", "kind": "imports"},
29352                {"from_id": "a", "to_id": "e", "kind": "mentions"},
29353                {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
29354                {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
29355                {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
29356                {"from_id": "a", "to_id": "i", "kind": "uses"},
29357                {"from_id": "a", "to_id": "j", "kind": "parent"},
29358                {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
29359            ]
29360        });
29361        let result = to_json_schema(&val, false, true, true, false).unwrap();
29362        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29363        let edges = &parsed["d"]["edges"].as_array().unwrap();
29364        assert_eq!(edges[0]["k"], "d");
29365        assert_eq!(edges[1]["k"], "ct");
29366        assert_eq!(edges[2]["k"], "i");
29367        assert_eq!(edges[3]["k"], "m");
29368        assert_eq!(edges[4]["k"], "sr");
29369        assert_eq!(edges[5]["k"], "bt");
29370        assert_eq!(edges[6]["k"], "sctx");
29371        assert_eq!(edges[7]["k"], "u");
29372        assert_eq!(edges[8]["k"], "p");
29373        assert_eq!(edges[9]["k"], "unknown_edge");
29374    }
29375
29376    #[test]
29377    fn ultra_terse_strips_provenance_freshness_from_edges() {
29378        let val = serde_json::json!({
29379            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
29380        });
29381        let result = to_json_schema(&val, false, true, true, false).unwrap();
29382        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29383        let edge = &parsed["d"]["edges"][0];
29384        assert!(edge.get("provenance").is_none());
29385        assert!(edge.get("freshness").is_none());
29386        assert_eq!(edge["k"], "c");
29387    }
29388
29389    #[test]
29390    fn ultra_terse_truncates_snippets() {
29391        let long_snippet = "x".repeat(120);
29392        let val = serde_json::json!({"snippet": long_snippet});
29393        let result = to_json_schema(&val, false, true, true, false).unwrap();
29394        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29395        let snipped = parsed["d"]["sn"].as_str().unwrap();
29396        assert_eq!(snipped.len(), 80);
29397        assert!(snipped.ends_with("..."));
29398    }
29399
29400    #[test]
29401    fn ultra_terse_truncates_abbreviated_snippet_key() {
29402        let long_snippet = "y".repeat(100);
29403        let val = serde_json::json!({"snippet": long_snippet});
29404        let result = to_json_schema(&val, false, true, true, false).unwrap();
29405        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29406        let snipped = parsed["d"]["sn"].as_str().unwrap();
29407        assert_eq!(snipped.len(), 80);
29408        assert!(snipped.ends_with("..."));
29409    }
29410
29411    #[test]
29412    fn ultra_terse_compacts_coverage_snapshot() {
29413        let val = serde_json::json!({
29414            "mode": "incremental",
29415            "total_sector_count": 10,
29416            "dirty_sector_count": 2,
29417            "active_rebuild": Some("rebuild-1"),
29418            "completed_dirty_sector_count": 1,
29419            "mounted_sector_count": 8,
29420            "rebuilding_sector_count": 1,
29421            "resumed_sector_count": 3,
29422            "reused_sector_count": 5
29423        });
29424        let result = to_json_schema(&val, false, true, true, false).unwrap();
29425        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29426        let d = &parsed["d"];
29427        assert_eq!(d["mode"], "incremental");
29428        assert_eq!(d["total_sector_count"], 10);
29429        assert_eq!(d["dirty_sector_count"], 2);
29430        assert!(d.get("active_rebuild").is_none());
29431        assert!(d.get("completed_dirty_sector_count").is_none());
29432        assert!(d.get("mounted_sector_count").is_none());
29433        assert!(d.get("rebuilding_sector_count").is_none());
29434        assert!(d.get("resumed_sector_count").is_none());
29435        assert!(d.get("reused_sector_count").is_none());
29436    }
29437
29438    #[test]
29439    fn ultra_terse_short_snippet_unchanged() {
29440        let val = serde_json::json!({"snippet": "short text"});
29441        let result = to_json_schema(&val, false, true, true, false).unwrap();
29442        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29443        assert_eq!(parsed["d"]["sn"], "short text");
29444    }
29445
29446    #[test]
29447    fn ultra_terse_non_graph_object_properties_preserved() {
29448        let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
29449        let result = to_json_schema(&val, false, true, true, false).unwrap();
29450        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29451        assert!(parsed["d"]["config"]["properties"].is_object());
29452    }
29453
29454    // --- schema-then-values ---
29455
29456    #[test]
29457    fn schema_converts_homogeneous_arrays() {
29458        let val = serde_json::json!({"symbols": [
29459            {"name": "foo", "kind": "fn", "line": 10},
29460            {"name": "bar", "kind": "fn", "line": 20}
29461        ]});
29462        let result = to_json_schema(&val, false, false, false, true).unwrap();
29463        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29464        let syms = &parsed["symbols"];
29465        let columns = syms["_c"]
29466            .as_array()
29467            .unwrap()
29468            .iter()
29469            .map(|value| value.as_str().unwrap())
29470            .collect::<Vec<_>>();
29471        let row0 = syms["_r"][0].as_array().unwrap();
29472        let row1 = syms["_r"][1].as_array().unwrap();
29473        let name_index = columns.iter().position(|column| *column == "name").unwrap();
29474        let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
29475        let line_index = columns.iter().position(|column| *column == "line").unwrap();
29476        assert_eq!(row0[name_index], "foo");
29477        assert_eq!(row0[kind_index], "fn");
29478        assert_eq!(row0[line_index], 10);
29479        assert_eq!(row1[name_index], "bar");
29480        assert_eq!(row1[kind_index], "fn");
29481        assert_eq!(row1[line_index], 20);
29482    }
29483
29484    #[test]
29485    fn schema_skips_short_arrays() {
29486        let val = serde_json::json!({"items": [{"name": "only"}]});
29487        let result = to_json_schema(&val, false, false, false, true).unwrap();
29488        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29489        assert!(parsed["items"].is_array());
29490        assert_eq!(parsed["items"][0]["name"], "only");
29491    }
29492
29493    #[test]
29494    fn schema_skips_heterogeneous_arrays() {
29495        let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
29496        let result = to_json_schema(&val, false, false, false, true).unwrap();
29497        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29498        assert!(parsed["items"].is_array());
29499        assert_eq!(parsed["items"][0]["a"], 1);
29500    }
29501
29502    #[test]
29503    fn schema_with_terse_combines() {
29504        let val = serde_json::json!({"callers": [
29505            {"caller_name": "a", "caller_file": "x.rs"},
29506            {"caller_name": "b", "caller_file": "y.rs"}
29507        ]});
29508        let result = to_json_schema(&val, false, true, false, true).unwrap();
29509        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29510        assert!(parsed["_s"].is_object());
29511        let d = &parsed["d"];
29512        let crs = &d["crs"];
29513        assert!(crs["_c"].is_array());
29514        assert!(crs["_r"].is_array());
29515        let columns = crs["_c"]
29516            .as_array()
29517            .unwrap()
29518            .iter()
29519            .map(|value| value.as_str().unwrap())
29520            .collect::<Vec<_>>();
29521        let row = crs["_r"][0].as_array().unwrap();
29522        let name_index = columns.iter().position(|column| *column == "cn").unwrap();
29523        let file_index = columns.iter().position(|column| *column == "cf").unwrap();
29524        assert_eq!(row[name_index], "a");
29525        assert_eq!(row[file_index], "x.rs");
29526    }
29527
29528    #[test]
29529    fn schema_preserves_non_object_arrays() {
29530        let val = serde_json::json!({"tags": ["a", "b", "c"]});
29531        let result = to_json_schema(&val, false, false, false, true).unwrap();
29532        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29533        assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
29534    }
29535
29536    #[test]
29537    fn cli_accepts_global_schema_flag() {
29538        let cli = parse_cli(["tsift", "--schema", "search", "test"]);
29539        assert!(cli.schema);
29540        assert!(matches!(cli.command, Some(Commands::Search { .. })));
29541    }
29542
29543    #[test]
29544    fn cli_accepts_global_envelope_flag() {
29545        let cli = parse_cli([
29546            "tsift",
29547            "--envelope",
29548            "context-pack",
29549            "tasks/software/tsift.md",
29550        ]);
29551        assert!(cli.envelope);
29552        assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
29553    }
29554
29555    #[test]
29556    fn cli_accepts_locks_command() {
29557        let cli = parse_cli(["tsift", "locks"]);
29558        assert!(matches!(cli.command, Some(Commands::Locks { .. })));
29559    }
29560
29561    #[test]
29562    fn cli_parses_memory_budget_guard_command() {
29563        let cli = parse_cli([
29564            "tsift",
29565            "memory",
29566            "budget-guard",
29567            "--file",
29568            "tool.log",
29569            "--budget-tokens",
29570            "1000",
29571            "--json",
29572        ]);
29573        match cli.command {
29574            Some(Commands::Memory {
29575                command:
29576                    crate::cli::MemoryCommand::BudgetGuard {
29577                        file,
29578                        budget_tokens,
29579                        json,
29580                        ..
29581                    },
29582            }) => {
29583                assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
29584                assert_eq!(budget_tokens, 1000);
29585                assert!(json);
29586            }
29587            _ => panic!("expected memory budget-guard command"),
29588        }
29589    }
29590
29591    #[test]
29592    fn cli_parses_memory_capture_agent_doc_closeout_command() {
29593        let cli = parse_cli([
29594            "tsift",
29595            "memory",
29596            "capture-agent-doc-closeout",
29597            ".",
29598            "--session-path",
29599            "tasks/software/tsift.md",
29600            "--prompt-target",
29601            "do [#tsiftmemhooks]",
29602            "--response-summary",
29603            "wired closeout capture",
29604            "--commit-hash",
29605            "abc123",
29606            "--session-check-status",
29607            "clean",
29608            "--json",
29609        ]);
29610        match cli.command {
29611            Some(Commands::Memory {
29612                command:
29613                    crate::cli::MemoryCommand::CaptureAgentDocCloseout {
29614                        path,
29615                        session_path,
29616                        prompt_target,
29617                        response_summary,
29618                        commit_hash,
29619                        session_check_status,
29620                        json,
29621                    },
29622            }) => {
29623                assert_eq!(path, std::path::PathBuf::from("."));
29624                assert_eq!(
29625                    session_path,
29626                    std::path::PathBuf::from("tasks/software/tsift.md")
29627                );
29628                assert_eq!(prompt_target, "do [#tsiftmemhooks]");
29629                assert_eq!(response_summary, "wired closeout capture");
29630                assert_eq!(commit_hash.as_deref(), Some("abc123"));
29631                assert_eq!(session_check_status, "clean");
29632                assert!(json);
29633            }
29634            _ => panic!("expected memory capture-agent-doc-closeout command"),
29635        }
29636    }
29637
29638    #[test]
29639    fn cli_parses_memory_project_graph_read_policy() {
29640        let cli = parse_cli([
29641            "tsift",
29642            "memory",
29643            "project-graph",
29644            ".",
29645            "--read-policy",
29646            "query-relevant",
29647            "--query",
29648            "semantic memory",
29649            "--limit",
29650            "7",
29651            "--json",
29652        ]);
29653        match cli.command {
29654            Some(Commands::Memory {
29655                command:
29656                    crate::cli::MemoryCommand::ProjectGraph {
29657                        read_policy,
29658                        query,
29659                        limit,
29660                        json,
29661                        ..
29662                    },
29663            }) => {
29664                assert_eq!(
29665                    read_policy,
29666                    crate::cli::MemoryProjectReadPolicy::QueryRelevant
29667                );
29668                assert_eq!(query.as_deref(), Some("semantic memory"));
29669                assert_eq!(limit, 7);
29670                assert!(json);
29671            }
29672            _ => panic!("expected memory project-graph command"),
29673        }
29674    }
29675
29676    #[test]
29677    fn cli_locks_accepts_scope_flag() {
29678        let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
29679        match cli.command {
29680            Some(Commands::Locks { scope, .. }) => {
29681                assert_eq!(scope.as_deref(), Some("alpha"));
29682            }
29683            _ => panic!("expected Locks command"),
29684        }
29685    }
29686
29687    #[test]
29688    fn cli_search_accepts_autoindex_flag() {
29689        let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
29690        match cli.command {
29691            Some(Commands::Search {
29692                autoindex,
29693                no_autoindex,
29694                ..
29695            }) => {
29696                assert!(autoindex);
29697                assert!(!no_autoindex);
29698            }
29699            _ => panic!("expected Search command"),
29700        }
29701    }
29702
29703    #[test]
29704    fn cli_search_accepts_exact_flag() {
29705        let cli = parse_cli(["tsift", "search", "test", "--exact"]);
29706        match cli.command {
29707            Some(Commands::Search {
29708                exact, strategy, ..
29709            }) => {
29710                assert!(exact);
29711                assert!(strategy.is_none());
29712            }
29713            _ => panic!("expected Search command"),
29714        }
29715    }
29716
29717    #[test]
29718    fn cli_parses_diff_digest_command() {
29719        let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
29720        match cli.command {
29721            Some(Commands::DiffDigest {
29722                json,
29723                path,
29724                cached,
29725                revision,
29726                max_parsed_files,
29727            }) => {
29728                assert!(json);
29729                assert_eq!(path, PathBuf::from("."));
29730                assert!(!cached);
29731                assert!(revision.is_none());
29732                assert_eq!(max_parsed_files, 25);
29733            }
29734            _ => panic!("expected DiffDigest command"),
29735        }
29736    }
29737
29738    #[test]
29739    fn cli_rejects_conflicting_diff_digest_modes() {
29740        match try_parse_cli([
29741            "tsift",
29742            "diff-digest",
29743            "--cached",
29744            "--revision",
29745            "HEAD",
29746            ".",
29747        ]) {
29748            Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
29749            Err(err) => {
29750                assert!(err.to_string().contains("--cached"));
29751                assert!(err.to_string().contains("--revision"));
29752            }
29753        }
29754    }
29755
29756    #[test]
29757    fn cli_parses_test_digest_command() {
29758        let cli = parse_cli([
29759            "tsift",
29760            "test-digest",
29761            "--path",
29762            ".",
29763            "--input",
29764            "target/test.log",
29765            "--runner",
29766            "cargo",
29767            "--json",
29768        ]);
29769        match cli.command {
29770            Some(Commands::TestDigest {
29771                json,
29772                path,
29773                input,
29774                runner,
29775            }) => {
29776                assert!(json);
29777                assert_eq!(path, PathBuf::from("."));
29778                assert_eq!(input, Some(PathBuf::from("target/test.log")));
29779                assert_eq!(runner.as_deref(), Some("cargo"));
29780            }
29781            _ => panic!("expected TestDigest command"),
29782        }
29783    }
29784
29785    #[test]
29786    fn cli_parses_log_digest_command() {
29787        let cli = parse_cli([
29788            "tsift",
29789            "log-digest",
29790            "--path",
29791            ".",
29792            "--input",
29793            "target/build.log",
29794            "--json",
29795        ]);
29796        match cli.command {
29797            Some(Commands::LogDigest {
29798                json,
29799                path,
29800                input,
29801                fixture,
29802                fail_under,
29803            }) => {
29804                assert!(json);
29805                assert_eq!(path, PathBuf::from("."));
29806                assert_eq!(input, Some(PathBuf::from("target/build.log")));
29807                assert!(fixture.is_none());
29808                assert!(!fail_under);
29809            }
29810            _ => panic!("expected LogDigest command"),
29811        }
29812    }
29813
29814    #[test]
29815    fn cli_parses_metric_digest_command() {
29816        let cli = parse_cli([
29817            "tsift",
29818            "metric-digest",
29819            "--input",
29820            "target/runs.json",
29821            "--baseline",
29822            "target/prior.json",
29823            "--metric",
29824            "session_mae",
29825            "--lower-is-better",
29826            "session_mae",
29827            "--history",
29828            "4",
29829            "--top",
29830            "2",
29831            "--json",
29832        ]);
29833        match cli.command {
29834            Some(Commands::MetricDigest {
29835                input,
29836                baseline,
29837                metrics,
29838                lower_is_better,
29839                history,
29840                top,
29841                json,
29842                ..
29843            }) => {
29844                assert!(json);
29845                assert_eq!(input, Some(PathBuf::from("target/runs.json")));
29846                assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
29847                assert_eq!(metrics, vec!["session_mae"]);
29848                assert_eq!(lower_is_better, vec!["session_mae"]);
29849                assert_eq!(history, 4);
29850                assert_eq!(top, 2);
29851            }
29852            _ => panic!("expected MetricDigest command"),
29853        }
29854    }
29855
29856    #[test]
29857    fn cli_parses_dci_benchmark_command() {
29858        let cli = parse_cli([
29859            "tsift",
29860            "dci-benchmark",
29861            "--fixture",
29862            "fixtures/dci-search-benchmark.json",
29863            "--json",
29864        ]);
29865        match cli.command {
29866            Some(Commands::DciBenchmark { fixture, json }) => {
29867                assert!(json);
29868                assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
29869            }
29870            _ => panic!("expected DciBenchmark command"),
29871        }
29872    }
29873
29874    #[test]
29875    fn cli_parses_session_digest_command() {
29876        let cli = parse_cli([
29877            "tsift",
29878            "session-digest",
29879            "--path",
29880            ".",
29881            "--input",
29882            "target/session.md",
29883            "--source",
29884            "markdown",
29885            "--json",
29886        ]);
29887        match cli.command {
29888            Some(Commands::SessionDigest {
29889                json,
29890                path,
29891                input,
29892                source,
29893            }) => {
29894                assert!(json);
29895                assert_eq!(path, PathBuf::from("."));
29896                assert_eq!(input, Some(PathBuf::from("target/session.md")));
29897                assert_eq!(source.as_deref(), Some("markdown"));
29898            }
29899            _ => panic!("expected SessionDigest command"),
29900        }
29901    }
29902
29903    #[test]
29904    fn cli_parses_session_cost_command() {
29905        let cli = parse_cli([
29906            "tsift",
29907            "session-cost",
29908            "--input",
29909            "target/session.jsonl",
29910            "--source",
29911            "codex-jsonl",
29912            "--json",
29913        ]);
29914        match cli.command {
29915            Some(Commands::SessionCost {
29916                json,
29917                input,
29918                fixture,
29919                fail_under,
29920                source,
29921            }) => {
29922                assert!(json);
29923                assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
29924                assert_eq!(fixture, None);
29925                assert!(!fail_under);
29926                assert_eq!(source.as_deref(), Some("codex-jsonl"));
29927            }
29928            _ => panic!("expected SessionCost command"),
29929        }
29930
29931        let cli = parse_cli([
29932            "tsift",
29933            "session-cost",
29934            "--fixture",
29935            "fixtures/real-session-prompt-cache-effectiveness.json",
29936            "--fail-under",
29937            "--json",
29938        ]);
29939        match cli.command {
29940            Some(Commands::SessionCost {
29941                json,
29942                input,
29943                fixture,
29944                fail_under,
29945                source,
29946            }) => {
29947                assert!(json);
29948                assert_eq!(input, None);
29949                assert_eq!(
29950                    fixture,
29951                    Some(PathBuf::from(
29952                        "fixtures/real-session-prompt-cache-effectiveness.json"
29953                    ))
29954                );
29955                assert!(fail_under);
29956                assert_eq!(source, None);
29957            }
29958            _ => panic!("expected SessionCost command"),
29959        }
29960    }
29961
29962    #[test]
29963    fn cli_parses_session_review_command() {
29964        let cli = parse_cli([
29965            "tsift",
29966            "session-review",
29967            "tasks/software/tsift.md",
29968            "--next-context",
29969            "--json",
29970        ]);
29971        match cli.command {
29972            Some(Commands::SessionReview {
29973                json,
29974                next_context,
29975                path,
29976                ..
29977            }) => {
29978                assert!(json);
29979                assert!(next_context);
29980                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29981            }
29982            _ => panic!("expected SessionReview command"),
29983        }
29984    }
29985
29986    #[test]
29987    fn cli_search_accepts_budget_flags() {
29988        let cli = parse_cli([
29989            "tsift",
29990            "search",
29991            "alpha_helper",
29992            "--max-items",
29993            "3",
29994            "--max-bytes",
29995            "96",
29996        ]);
29997        match cli.command {
29998            Some(Commands::Search {
29999                max_items,
30000                max_bytes,
30001                ..
30002            }) => {
30003                assert_eq!(max_items, Some(3));
30004                assert_eq!(max_bytes, Some(96));
30005            }
30006            _ => panic!("expected Search command"),
30007        }
30008    }
30009
30010    #[test]
30011    fn cli_search_accepts_budget_preset() {
30012        let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
30013        match cli.command {
30014            Some(Commands::Search { budget, .. }) => {
30015                assert_eq!(budget, Some(ResponseBudgetPreset::Small));
30016            }
30017            _ => panic!("expected Search command"),
30018        }
30019    }
30020
30021    #[test]
30022    fn cli_search_accepts_ast_facet_filters() {
30023        let cli = parse_cli([
30024            "tsift",
30025            "search",
30026            "setup",
30027            "--lang",
30028            "markdown",
30029            "--kind",
30030            "list_item",
30031            "--node-kind",
30032            "list_item",
30033            "--section",
30034            "Install",
30035            "--parent",
30036            "Run setup.",
30037            "--child",
30038            "Confirm setup.",
30039            "--fence-language",
30040            "rust",
30041            "--list-depth",
30042            "1",
30043            "--heading-level",
30044            "2",
30045        ]);
30046        match cli.command {
30047            Some(Commands::Search {
30048                lang,
30049                kind,
30050                node_kind,
30051                section,
30052                parent,
30053                child,
30054                fence_language,
30055                list_depth,
30056                heading_level,
30057                ..
30058            }) => {
30059                assert_eq!(lang, vec!["markdown"]);
30060                assert_eq!(kind, vec!["list_item"]);
30061                assert_eq!(node_kind, vec!["list_item"]);
30062                assert_eq!(section, vec!["Install"]);
30063                assert_eq!(parent, vec!["Run setup."]);
30064                assert_eq!(child, vec!["Confirm setup."]);
30065                assert_eq!(fence_language, vec!["rust"]);
30066                assert_eq!(list_depth, vec![1]);
30067                assert_eq!(heading_level, vec![2]);
30068            }
30069            _ => panic!("expected Search command"),
30070        }
30071    }
30072
30073    #[test]
30074    fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
30075        let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
30076        assert_eq!(small.preview_items(), 3);
30077        assert_eq!(small.preview_bytes(), 120);
30078        assert_eq!(small.follow_up_items(), 4);
30079
30080        let overridden =
30081            ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
30082        assert_eq!(overridden.preview_items(), 7);
30083        assert_eq!(overridden.preview_bytes(), 120);
30084        assert_eq!(overridden.follow_up_items(), 7);
30085
30086        let envelope_default = ResponseBudget::from_cli(None, None, None, true);
30087        assert!(envelope_default.is_active());
30088    }
30089
30090    #[test]
30091    fn cli_explain_accepts_budget_flags() {
30092        let cli = parse_cli([
30093            "tsift",
30094            "explain",
30095            "alpha_helper",
30096            "--max-items",
30097            "2",
30098            "--max-bytes",
30099            "80",
30100        ]);
30101        match cli.command {
30102            Some(Commands::Explain {
30103                max_items,
30104                max_bytes,
30105                ..
30106            }) => {
30107                assert_eq!(max_items, Some(2));
30108                assert_eq!(max_bytes, Some(80));
30109            }
30110            _ => panic!("expected Explain command"),
30111        }
30112    }
30113
30114    #[test]
30115    fn cli_session_review_accepts_budget_flags() {
30116        let cli = parse_cli([
30117            "tsift",
30118            "session-review",
30119            "tasks/software/tsift.md",
30120            "--max-items",
30121            "4",
30122            "--max-bytes",
30123            "120",
30124        ]);
30125        match cli.command {
30126            Some(Commands::SessionReview {
30127                max_items,
30128                max_bytes,
30129                ..
30130            }) => {
30131                assert_eq!(max_items, Some(4));
30132                assert_eq!(max_bytes, Some(120));
30133            }
30134            _ => panic!("expected SessionReview command"),
30135        }
30136    }
30137
30138    #[test]
30139    fn cli_parses_context_pack_command() {
30140        let cli = parse_cli([
30141            "tsift",
30142            "context-pack",
30143            "tasks/software/tsift.md",
30144            "--test-input",
30145            "target/test.log",
30146            "--runner",
30147            "cargo",
30148            "--log-input",
30149            "target/build.log",
30150            "--max-items",
30151            "3",
30152            "--max-bytes",
30153            "96",
30154            "--json",
30155        ]);
30156        match cli.command {
30157            Some(Commands::ContextPack {
30158                path,
30159                test_input,
30160                runner,
30161                log_input,
30162                json,
30163                max_items,
30164                max_bytes,
30165                budget,
30166                convex_snapshot,
30167            }) => {
30168                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
30169                assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
30170                assert_eq!(runner.as_deref(), Some("cargo"));
30171                assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
30172                assert!(json);
30173                assert_eq!(max_items, Some(3));
30174                assert_eq!(max_bytes, Some(96));
30175                assert!(budget.is_none());
30176                assert!(convex_snapshot.is_none());
30177            }
30178            _ => panic!("expected ContextPack command"),
30179        }
30180    }
30181
30182    #[test]
30183    fn cli_parses_token_savings_command() {
30184        let cli = parse_cli([
30185            "tsift",
30186            "token-savings",
30187            "--fixture",
30188            "fixtures/tsift-token-savings.json",
30189            "--fail-under",
30190            "--json",
30191        ]);
30192        match cli.command {
30193            Some(Commands::TokenSavings {
30194                fixture,
30195                fail_under,
30196                json,
30197            }) => {
30198                assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
30199                assert!(fail_under);
30200                assert!(json);
30201            }
30202            _ => panic!("expected TokenSavings command"),
30203        }
30204    }
30205
30206    #[test]
30207    fn token_savings_report_records_fixture_thresholds() {
30208        let raw_symbols = [
30209            "validate_user",
30210            "validateUser",
30211            "ValidateUser",
30212            "validate-user",
30213            "VALIDATE_USER",
30214            "Validate_User",
30215            "raw_symbol",
30216            "rawSymbol",
30217            "RawSymbol",
30218            "raw-symbol",
30219            "RAW_SYMBOL",
30220            "Raw_Symbol",
30221        ]
30222        .iter()
30223        .enumerate()
30224        .map(|(idx, identifier)| TokenSavingsRawSymbol {
30225            identifier: (*identifier).to_string(),
30226            file: format!("src/example_{idx}.rs"),
30227            line: (idx + 1) as u64,
30228            context: "function".to_string(),
30229        })
30230        .collect();
30231        let fixture = TokenSavingsFixture {
30232            schema_version: 1,
30233            description: "fixture".to_string(),
30234            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30235            cases: vec![TokenSavingsFixtureCase {
30236                name: "search-preview".to_string(),
30237                surface: "search".to_string(),
30238                minimum_savings_percent: 40.0,
30239                raw_symbols,
30240                tagpath_families: vec![
30241                    TokenSavingsFamily {
30242                        canonical: "validate_user".to_string(),
30243                        count: 6,
30244                        aliases: BTreeMap::new(),
30245                    },
30246                    TokenSavingsFamily {
30247                        canonical: "raw_symbol".to_string(),
30248                        count: 6,
30249                        aliases: BTreeMap::new(),
30250                    },
30251                ],
30252                context_pack_inputs: None,
30253                session_review_inputs: None,
30254                source_read_inputs: None,
30255                markdown_projection_inputs: None,
30256            }],
30257        };
30258
30259        let report = build_token_savings_report(&fixture).unwrap();
30260
30261        assert!(report.pass);
30262        assert_eq!(report.cases[0].raw_symbol_count, 12);
30263        assert_eq!(report.cases[0].family_count, 2);
30264        assert_eq!(report.cases[0].status, "pass");
30265        assert!(report.cases[0].byte_delta > 0);
30266        assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
30267        assert!(report.cases[0].savings_percent >= 40.0);
30268    }
30269
30270    #[test]
30271    fn token_savings_source_read_inputs_preserve_required_anchors() {
30272        let fixture = TokenSavingsFixture {
30273            schema_version: 1,
30274            description: "fixture".to_string(),
30275            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30276            cases: vec![TokenSavingsFixtureCase {
30277                name: "source-read".to_string(),
30278                surface: "source-read".to_string(),
30279                minimum_savings_percent: 40.0,
30280                raw_symbols: Vec::new(),
30281                tagpath_families: Vec::new(),
30282                context_pack_inputs: None,
30283                session_review_inputs: None,
30284                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30285                    reads: vec![TokenSavingsSourceReadInput {
30286                        command: "sed -n '40,160p' src/main.rs".to_string(),
30287                        file: "src/main.rs".to_string(),
30288                        raw_start: 40,
30289                        raw_lines: 121,
30290                        raw_excerpt: "line 40\n".repeat(121),
30291                        envelope_start: 40,
30292                        envelope_lines: 121,
30293                        required_line_anchors: vec![40, 120, 160],
30294                    }],
30295                }),
30296                markdown_projection_inputs: None,
30297            }],
30298        };
30299
30300        let report = build_token_savings_report(&fixture).unwrap();
30301
30302        assert!(report.pass);
30303        assert_eq!(report.cases[0].surface, "source-read");
30304        assert!(report.cases[0].savings_percent >= 40.0);
30305    }
30306
30307    #[test]
30308    fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
30309        let fixture = TokenSavingsFixture {
30310            schema_version: 1,
30311            description: "fixture".to_string(),
30312            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30313            cases: vec![TokenSavingsFixtureCase {
30314                name: "source-read".to_string(),
30315                surface: "source-read".to_string(),
30316                minimum_savings_percent: 40.0,
30317                raw_symbols: Vec::new(),
30318                tagpath_families: Vec::new(),
30319                context_pack_inputs: None,
30320                session_review_inputs: None,
30321                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30322                    reads: vec![TokenSavingsSourceReadInput {
30323                        command: "cat src/main.rs".to_string(),
30324                        file: "src/main.rs".to_string(),
30325                        raw_start: 1,
30326                        raw_lines: 200,
30327                        raw_excerpt: "line\n".repeat(200),
30328                        envelope_start: 1,
30329                        envelope_lines: 80,
30330                        required_line_anchors: vec![120],
30331                    }],
30332                }),
30333                markdown_projection_inputs: None,
30334            }],
30335        };
30336
30337        let err = match build_token_savings_report(&fixture) {
30338            Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
30339            Err(err) => err,
30340        };
30341
30342        assert!(err.to_string().contains("hides required line anchor 120"));
30343    }
30344
30345    #[test]
30346    fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
30347        let fixture = TokenSavingsFixture {
30348            schema_version: 1,
30349            description: "fixture".to_string(),
30350            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30351            cases: vec![TokenSavingsFixtureCase {
30352                name: "markdown-projection".to_string(),
30353                surface: "context-pack".to_string(),
30354                minimum_savings_percent: 40.0,
30355                raw_symbols: Vec::new(),
30356                tagpath_families: Vec::new(),
30357                context_pack_inputs: None,
30358                session_review_inputs: None,
30359                source_read_inputs: None,
30360                markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
30361                    documents: vec![TokenSavingsMarkdownProjectionInput {
30362                        command: "context-pack markdown body".to_string(),
30363                        file: "tasks/software/tsift.md".to_string(),
30364                        raw_markdown: "# Heading\n\n".repeat(120),
30365                        outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
30366                        selected_nodes: vec!["mdast-selected".to_string()],
30367                        expand:
30368                            "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
30369                                .to_string(),
30370                    }],
30371                }),
30372            }],
30373        };
30374
30375        let report = build_token_savings_report(&fixture).unwrap();
30376
30377        assert!(report.pass);
30378        assert_eq!(report.cases[0].surface, "context-pack");
30379        assert!(report.cases[0].savings_percent >= 40.0);
30380    }
30381
30382    #[test]
30383    fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
30384        let mut content = String::from("# Cache Root\n\n");
30385        for idx in 0..96 {
30386            content.push_str(&format!(
30387                "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
30388            ));
30389        }
30390
30391        let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30392        assert!(!first.cache_hit);
30393        assert!(first.nodes.len() > 200);
30394
30395        let sections = markdown_section_spans(&content).unwrap();
30396        let list_items = markdown_block_spans(&content, "list_item").unwrap();
30397        let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
30398        let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30399
30400        assert!(second.cache_hit);
30401        assert_eq!(second.nodes.len(), first.nodes.len());
30402        assert_eq!(sections.len(), 97);
30403        assert_eq!(list_items.len(), 96);
30404        assert_eq!(code_blocks.len(), 96);
30405        let first_code = first
30406            .nodes
30407            .iter()
30408            .find(|node| node.kind == "code_block")
30409            .expect("expected a Markdown code block");
30410        let first_code_node = markdown_ast_node(
30411            Path::new("/repo"),
30412            "semantic-edit",
30413            first_code,
30414            content.as_bytes(),
30415            &first.nodes,
30416            8,
30417        );
30418        assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
30419        assert_eq!(
30420            first_code_node.metadata.embedded_symbols[0].name,
30421            "sample_0"
30422        );
30423        assert_eq!(
30424            first_code_node.metadata.embedded_symbols[0].language,
30425            "rust"
30426        );
30427    }
30428
30429    #[test]
30430    fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
30431        let response = empty_search_response(Path::new("/repo"), "lexical");
30432        let symbol_hits = vec![index::SymbolHit {
30433            name: "alpha_helper_with_a_long_name".to_string(),
30434            kind: "function".to_string(),
30435            language: "rust".to_string(),
30436            file: "/repo/src/lib.rs".to_string(),
30437            line: 12,
30438            end_line: None,
30439            node_kind: None,
30440            start_byte: None,
30441            end_byte: None,
30442            body_start_byte: None,
30443            body_end_byte: None,
30444            tags: None,
30445            score: 0.98,
30446            match_type: "exact_name".to_string(),
30447            tagpath_handle: None,
30448        }];
30449
30450        let report = build_relative_search_budget_report(
30451            "alpha_helper_with_a_long_name",
30452            "lexical",
30453            Path::new("/repo"),
30454            &response,
30455            &symbol_hits,
30456            ResponseBudget::new(Some(1), Some(12)),
30457            &SearchFacetFilters::default(),
30458        );
30459
30460        assert_eq!(report.symbols.len(), 1);
30461        assert!(report.symbols[0].handle.starts_with("sfam-"));
30462        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
30463        assert_eq!(report.symbols[0].name, "alpha_hel...");
30464        assert_eq!(report.symbols[0].file, "src/lib.rs");
30465        assert!(report.symbols[0].expand.contains("tsift search"));
30466    }
30467
30468    #[test]
30469    fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
30470        let dir = tempfile::tempdir().unwrap();
30471        let src_dir = dir.path().join("src");
30472        fs::create_dir_all(&src_dir).unwrap();
30473        let source = "fn alpha_helper() {\n    beta();\n}\n";
30474        let file = src_dir.join("lib.rs");
30475        fs::write(&file, source).unwrap();
30476        let body_start = source.find("{\n").unwrap() + 1;
30477        let body_end = source.rfind("\n}").unwrap() + 1;
30478
30479        let response = empty_search_response(dir.path(), "lexical");
30480        let symbol_hits = vec![index::SymbolHit {
30481            name: "alpha_helper".to_string(),
30482            kind: "function".to_string(),
30483            language: "rust".to_string(),
30484            file: file.to_string_lossy().to_string(),
30485            line: 0,
30486            end_line: Some(2),
30487            node_kind: Some("function_item".to_string()),
30488            start_byte: Some(0),
30489            end_byte: Some(i64::try_from(source.len()).unwrap()),
30490            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30491            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30492            tags: Some("alpha,helper".to_string()),
30493            score: 0.98,
30494            match_type: "exact_name".to_string(),
30495            tagpath_handle: None,
30496        }];
30497
30498        let report = build_relative_search_budget_report(
30499            "alpha helper",
30500            "lexical",
30501            dir.path(),
30502            &response,
30503            &symbol_hits,
30504            ResponseBudget::new(Some(5), Some(96)),
30505            &SearchFacetFilters::default(),
30506        );
30507
30508        let symbol = &report.symbols[0];
30509        assert_eq!(symbol.language, "rust");
30510        assert_eq!(symbol.end_line, Some(2));
30511        let ast = symbol
30512            .ast
30513            .as_ref()
30514            .expect("search symbol preview should expose an AST span artifact");
30515        assert_eq!(ast.artifact_kind, "ast_span");
30516        assert!(ast.span.handle.starts_with("span-"));
30517        assert_eq!(ast.span.node_kind, "function_item");
30518        assert_eq!(ast.span.start_byte, 0);
30519        assert_eq!(ast.span.end_byte, source.len());
30520        assert_eq!(ast.span.body_start_byte, Some(body_start));
30521        assert_eq!(ast.span.body_end_byte, Some(body_end));
30522        assert!(ast.expand.source_window.contains("source-read"));
30523        assert!(
30524            ast.expand
30525                .source_body
30526                .as_ref()
30527                .unwrap()
30528                .contains("source-read")
30529        );
30530        assert!(ast.expand.symbol_read.contains("symbol-read"));
30531        assert!(ast.expand.markdown_ast.is_none());
30532    }
30533
30534    #[test]
30535    fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
30536        let dir = tempfile::tempdir().unwrap();
30537        let source = "# Guide\n\n## Install\n\n- Run setup.\n";
30538        let file = dir.path().join("README.md");
30539        fs::write(&file, source).unwrap();
30540        let heading_start = source.find("## Install").unwrap();
30541        let heading_end = source.len();
30542
30543        let response = empty_search_response(dir.path(), "lexical");
30544        let symbol_hits = vec![index::SymbolHit {
30545            name: "Install".to_string(),
30546            kind: "heading".to_string(),
30547            language: "markdown".to_string(),
30548            file: file.to_string_lossy().to_string(),
30549            line: 2,
30550            end_line: Some(4),
30551            node_kind: Some("atx_heading".to_string()),
30552            start_byte: Some(i64::try_from(heading_start).unwrap()),
30553            end_byte: Some(i64::try_from(heading_end).unwrap()),
30554            body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
30555            body_end_byte: Some(i64::try_from(heading_end).unwrap()),
30556            tags: Some("install".to_string()),
30557            score: 1.0,
30558            match_type: "exact_name".to_string(),
30559            tagpath_handle: None,
30560        }];
30561
30562        let report = build_relative_search_budget_report(
30563            "Install",
30564            "lexical",
30565            dir.path(),
30566            &response,
30567            &symbol_hits,
30568            ResponseBudget::new(Some(5), Some(96)),
30569            &SearchFacetFilters::default(),
30570        );
30571
30572        let ast = report.symbols[0]
30573            .ast
30574            .as_ref()
30575            .expect("Markdown search symbol should expose an AST span artifact");
30576        assert_eq!(ast.span.node_kind, "atx_heading");
30577        assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
30578        let markdown_ast = ast
30579            .expand
30580            .markdown_ast
30581            .as_ref()
30582            .expect("Markdown symbols should include markdown-ast expansion");
30583        assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
30584        assert!(markdown_ast.contains("--node"), "{markdown_ast}");
30585        assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
30586        assert!(ast.expand.source_window.contains("source-read"));
30587        assert!(ast.expand.symbol_read.contains("symbol-read"));
30588    }
30589
30590    #[test]
30591    fn search_budget_report_exposes_markdown_embedded_code_symbols() {
30592        let dir = tempfile::tempdir().unwrap();
30593        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30594        let file = dir.path().join("README.md");
30595        fs::write(&file, source).unwrap();
30596        let fence_start = source.find("```rust").unwrap();
30597        let body_start = source.find("fn sample").unwrap();
30598        let body_end = body_start + "fn sample() {}\n".len();
30599
30600        let response = empty_search_response(dir.path(), "lexical");
30601        let symbol_hits = vec![index::SymbolHit {
30602            name: "rust".to_string(),
30603            kind: "code_block".to_string(),
30604            language: "markdown".to_string(),
30605            file: file.to_string_lossy().to_string(),
30606            line: 2,
30607            end_line: Some(4),
30608            node_kind: Some("fenced_code_block".to_string()),
30609            start_byte: Some(i64::try_from(fence_start).unwrap()),
30610            end_byte: Some(i64::try_from(source.len()).unwrap()),
30611            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30612            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30613            tags: Some("rust".to_string()),
30614            score: 1.0,
30615            match_type: "exact_name".to_string(),
30616            tagpath_handle: None,
30617        }];
30618
30619        let report = build_relative_search_budget_report(
30620            "rust",
30621            "lexical",
30622            dir.path(),
30623            &response,
30624            &symbol_hits,
30625            ResponseBudget::new(Some(5), Some(96)),
30626            &SearchFacetFilters::default(),
30627        );
30628
30629        let embedded = &report.symbols[0]
30630            .ast
30631            .as_ref()
30632            .unwrap()
30633            .span
30634            .markdown
30635            .as_ref()
30636            .unwrap()
30637            .embedded_symbols;
30638        assert_eq!(embedded.len(), 1);
30639        assert_eq!(embedded[0].name, "sample");
30640        assert_eq!(embedded[0].kind, "function");
30641        assert_eq!(embedded[0].language, "rust");
30642        assert_eq!(embedded[0].node_kind, "function_item");
30643        assert!(embedded[0].handle.starts_with("span-"));
30644        assert_eq!(embedded[0].start_byte, body_start);
30645        assert_eq!(embedded[0].start_line, 4);
30646    }
30647
30648    fn test_lexical_search_hit(
30649        path: &Path,
30650        rank: usize,
30651        score: f64,
30652        snippet: &str,
30653    ) -> sift::SearchHit {
30654        sift::SearchHit {
30655            artifact_id: format!("hit-{rank}"),
30656            artifact_kind: sift::ContextArtifactKind::File,
30657            budget: sift::ArtifactBudget::from_text(snippet, 1),
30658            confidence: sift::ScoreConfidence::High,
30659            freshness: sift::ArtifactFreshness {
30660                modified_unix_secs: None,
30661                observed_unix_secs: 0,
30662            },
30663            location: Some("line 1".to_string()),
30664            path: path.to_string_lossy().to_string(),
30665            provenance: sift::ArtifactProvenance {
30666                adapter: sift::AcquisitionAdapterKind::FileSystem,
30667                source: "test lexical hit".to_string(),
30668                synthetic: false,
30669            },
30670            rank,
30671            score,
30672            snippet: snippet.to_string(),
30673        }
30674    }
30675
30676    fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
30677        summarize::Summary {
30678            id: 0,
30679            symbol_name: symbol_name.to_string(),
30680            file_path: file_path.to_string(),
30681            content_hash: "hash".to_string(),
30682            summary: summary.to_string(),
30683            entities: None,
30684            relationships: None,
30685            concept_labels: None,
30686            extracted_at: "2026-06-02T00:00:00Z".to_string(),
30687            model: "test".to_string(),
30688            tokens_input: None,
30689            tokens_output: None,
30690        }
30691    }
30692
30693    #[test]
30694    fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
30695        let dir = tempfile::tempdir().unwrap();
30696        let src_dir = dir.path().join("src");
30697        fs::create_dir_all(&src_dir).unwrap();
30698        let source = "fn alpha_helper() {}\n";
30699        let file = src_dir.join("lib.rs");
30700        let broad_file = dir.path().join("README.md");
30701        fs::write(&file, source).unwrap();
30702        fs::write(
30703            &broad_file,
30704            "alpha helper alpha helper alpha helper in prose\n",
30705        )
30706        .unwrap();
30707
30708        let mut response = empty_search_response(dir.path(), "lexical");
30709        response.hits.push(test_lexical_search_hit(
30710            &broad_file,
30711            1,
30712            240.0,
30713            "alpha helper alpha helper alpha helper in prose",
30714        ));
30715        let symbol_hits = vec![index::SymbolHit {
30716            name: "alpha_helper".to_string(),
30717            kind: "function".to_string(),
30718            language: "rust".to_string(),
30719            file: file.to_string_lossy().to_string(),
30720            line: 0,
30721            end_line: Some(0),
30722            node_kind: Some("function_item".to_string()),
30723            start_byte: Some(0),
30724            end_byte: Some(i64::try_from(source.len()).unwrap()),
30725            body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30726            body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30727            tags: Some("alpha,helper".to_string()),
30728            score: 0.8,
30729            match_type: "all_tags".to_string(),
30730            tagpath_handle: None,
30731        }];
30732
30733        let report = build_relative_search_budget_report(
30734            "alpha helper",
30735            "lexical",
30736            dir.path(),
30737            &response,
30738            &symbol_hits,
30739            ResponseBudget::new(Some(5), Some(128)),
30740            &SearchFacetFilters::default(),
30741        );
30742
30743        assert_eq!(report.ranked[0].source, "symbol_span");
30744        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30745        assert!(report.ranked[0].score > report.ranked[1].score);
30746        assert_eq!(report.ranked[1].source, "lexical_file");
30747    }
30748
30749    #[test]
30750    fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
30751        let dir = tempfile::tempdir().unwrap();
30752        let src_dir = dir.path().join("src");
30753        fs::create_dir_all(&src_dir).unwrap();
30754        let source = "fn alpha_helper() {\n    let needle = \"needle\";\n}\n\nfn other() {}\n";
30755        let file = src_dir.join("lib.rs");
30756        fs::write(&file, source).unwrap();
30757
30758        let mut response = empty_search_response(dir.path(), "exact");
30759        let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
30760        hit.location = Some("line 2".to_string());
30761        response.hits.push(hit);
30762
30763        let symbol_hits = vec![index::SymbolHit {
30764            name: "alpha_helper".to_string(),
30765            kind: "function".to_string(),
30766            language: "rust".to_string(),
30767            file: file.to_string_lossy().to_string(),
30768            line: 0,
30769            end_line: Some(2),
30770            node_kind: Some("function_item".to_string()),
30771            start_byte: Some(0),
30772            end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
30773            body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
30774            body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
30775            tags: Some("alpha,helper".to_string()),
30776            score: 0.9,
30777            match_type: "all_tags".to_string(),
30778            tagpath_handle: None,
30779        }];
30780
30781        let report = build_relative_search_budget_report(
30782            "needle",
30783            "exact",
30784            dir.path(),
30785            &response,
30786            &symbol_hits,
30787            ResponseBudget::new(Some(5), Some(128)),
30788            &SearchFacetFilters::default(),
30789        );
30790
30791        let hit = &report.hits[0];
30792        assert_eq!(hit.line, Some(2));
30793        let source_handle = hit
30794            .source_handle
30795            .as_ref()
30796            .expect("exact hit should expose a bounded source_handle window");
30797        assert!(source_handle.handle.starts_with("xwin-"));
30798        assert_eq!(source_handle.kind, "source_handle");
30799        assert_eq!(source_handle.file, "src/lib.rs");
30800        assert_eq!(source_handle.start_line, 1);
30801        assert_eq!(source_handle.end_line, 3);
30802        assert!(source_handle.expand.contains("source-read"));
30803
30804        let containing_symbol = hit
30805            .containing_symbol
30806            .as_ref()
30807            .expect("exact hit should expose its containing symbol when indexed");
30808        assert_eq!(containing_symbol.name, "alpha_helper");
30809        assert_eq!(containing_symbol.kind, "function");
30810        assert_eq!(containing_symbol.line, 1);
30811        assert_eq!(containing_symbol.end_line, Some(3));
30812        assert!(containing_symbol.expand.contains("symbol-read"));
30813
30814        let lexical_rank = report
30815            .ranked
30816            .iter()
30817            .find(|item| item.source == "lexical_file")
30818            .expect("ranked preview should retain the lexical retrieval handle");
30819        assert!(
30820            lexical_rank
30821                .reasons
30822                .iter()
30823                .any(|reason| reason == "source_handle")
30824        );
30825        assert!(
30826            lexical_rank
30827                .reasons
30828                .iter()
30829                .any(|reason| reason == "containing_symbol")
30830        );
30831    }
30832
30833    #[test]
30834    fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
30835        let dir = tempfile::tempdir().unwrap();
30836        let src_dir = dir.path().join("src");
30837        let tests_dir = dir.path().join("tests");
30838        fs::create_dir_all(&src_dir).unwrap();
30839        fs::create_dir_all(&tests_dir).unwrap();
30840        let source_file = src_dir.join("lib.rs");
30841        let test_file = tests_dir.join("alpha_test.rs");
30842        fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
30843        fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
30844
30845        let response = empty_search_response(dir.path(), "lexical");
30846        let symbol_hits = vec![
30847            index::SymbolHit {
30848                name: "alpha_helper_test".to_string(),
30849                kind: "function".to_string(),
30850                language: "rust".to_string(),
30851                file: test_file.to_string_lossy().to_string(),
30852                line: 1,
30853                end_line: Some(1),
30854                node_kind: Some("function_item".to_string()),
30855                start_byte: Some(8),
30856                end_byte: Some(33),
30857                body_start_byte: Some(31),
30858                body_end_byte: Some(31),
30859                tags: Some("alpha,helper,test".to_string()),
30860                score: 1.0,
30861                match_type: "exact_name".to_string(),
30862                tagpath_handle: None,
30863            },
30864            index::SymbolHit {
30865                name: "alpha_helper".to_string(),
30866                kind: "function".to_string(),
30867                language: "rust".to_string(),
30868                file: source_file.to_string_lossy().to_string(),
30869                line: 0,
30870                end_line: Some(0),
30871                node_kind: Some("function_item".to_string()),
30872                start_byte: Some(0),
30873                end_byte: Some(20),
30874                body_start_byte: Some(18),
30875                body_end_byte: Some(18),
30876                tags: Some("alpha,helper".to_string()),
30877                score: 0.78,
30878                match_type: "all_tags".to_string(),
30879                tagpath_handle: None,
30880            },
30881        ];
30882
30883        let report = build_relative_search_budget_report(
30884            "alpha helper",
30885            "lexical",
30886            dir.path(),
30887            &response,
30888            &symbol_hits,
30889            ResponseBudget::new(Some(5), Some(128)),
30890            &SearchFacetFilters::default(),
30891        );
30892
30893        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30894        assert_eq!(report.ranked[0].path, "src/lib.rs");
30895        assert!(
30896            report.ranked[0]
30897                .reasons
30898                .iter()
30899                .any(|reason| reason == "definition_kind")
30900        );
30901        assert!(
30902            report.ranked[0]
30903                .reasons
30904                .iter()
30905                .any(|reason| reason == "source_path")
30906        );
30907        let test_rank = report
30908            .ranked
30909            .iter()
30910            .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
30911            .expect("test symbol should still be present in the ranked preview");
30912        assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
30913    }
30914
30915    #[test]
30916    fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
30917        let dir = tempfile::tempdir().unwrap();
30918        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30919        let file = dir.path().join("README.md");
30920        fs::write(&file, source).unwrap();
30921        let summary_db =
30922            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
30923        summary_db
30924            .insert(&test_summary(
30925                "rust",
30926                "README.md",
30927                "Rust fence contains a sample function.",
30928            ))
30929            .unwrap();
30930
30931        let fence_start = source.find("```rust").unwrap();
30932        let body_start = source.find("fn sample").unwrap();
30933        let body_end = body_start + "fn sample() {}\n".len();
30934        let response = empty_search_response(dir.path(), "lexical");
30935        let symbol_hits = vec![index::SymbolHit {
30936            name: "rust".to_string(),
30937            kind: "code_block".to_string(),
30938            language: "markdown".to_string(),
30939            file: file.to_string_lossy().to_string(),
30940            line: 2,
30941            end_line: Some(4),
30942            node_kind: Some("fenced_code_block".to_string()),
30943            start_byte: Some(i64::try_from(fence_start).unwrap()),
30944            end_byte: Some(i64::try_from(source.len()).unwrap()),
30945            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30946            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30947            tags: Some("rust".to_string()),
30948            score: 1.0,
30949            match_type: "exact_name".to_string(),
30950            tagpath_handle: None,
30951        }];
30952
30953        let report = build_relative_search_budget_report(
30954            "rust",
30955            "lexical",
30956            dir.path(),
30957            &response,
30958            &symbol_hits,
30959            ResponseBudget::new(Some(5), Some(128)),
30960            &SearchFacetFilters::default(),
30961        );
30962
30963        let symbol = &report.symbols[0];
30964        assert_eq!(symbol.summary_refs, 1);
30965        assert_eq!(symbol.graph_neighbors, 1);
30966        assert!(
30967            report.ranked[0]
30968                .reasons
30969                .iter()
30970                .any(|reason| reason == "summary_refs:1")
30971        );
30972        assert!(
30973            report.ranked[0]
30974                .reasons
30975                .iter()
30976                .any(|reason| reason == "graph_neighbors:1")
30977        );
30978    }
30979
30980    fn markdown_search_facet_fixture() -> tempfile::TempDir {
30981        let dir = tempfile::tempdir().unwrap();
30982        let source = r#"# Guide
30983
30984## Install
30985
30986- Run setup.
30987  - Confirm setup.
30988
30989```rust
30990fn sample() {}
30991```
30992"#;
30993        fs::write(dir.path().join("README.md"), source).unwrap();
30994        let index_dir = dir.path().join(".tsift");
30995        fs::create_dir_all(&index_dir).unwrap();
30996        run_index_update(
30997            &index_dir.join("index.db"),
30998            dir.path(),
30999            "indexing markdown search facet fixture".to_string(),
31000            dir.path(),
31001            None,
31002            false,
31003            false,
31004        )
31005        .unwrap();
31006        dir
31007    }
31008
31009    fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
31010        let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
31011        db.symbol_search(query, 20).unwrap()
31012    }
31013
31014    #[test]
31015    fn search_facet_filters_match_scalar_symbol_fields() {
31016        let dir = tempfile::tempdir().unwrap();
31017        let hits = vec![
31018            index::SymbolHit {
31019                name: "alpha_helper".to_string(),
31020                kind: "function".to_string(),
31021                language: "rust".to_string(),
31022                file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
31023                line: 0,
31024                end_line: None,
31025                node_kind: Some("function_item".to_string()),
31026                start_byte: None,
31027                end_byte: None,
31028                body_start_byte: None,
31029                body_end_byte: None,
31030                tags: None,
31031                score: 1.0,
31032                match_type: "exact_name".to_string(),
31033                tagpath_handle: None,
31034            },
31035            index::SymbolHit {
31036                name: "Install".to_string(),
31037                kind: "heading".to_string(),
31038                language: "markdown".to_string(),
31039                file: dir.path().join("README.md").to_string_lossy().to_string(),
31040                line: 0,
31041                end_line: None,
31042                node_kind: Some("atx_heading".to_string()),
31043                start_byte: None,
31044                end_byte: None,
31045                body_start_byte: None,
31046                body_end_byte: None,
31047                tags: None,
31048                score: 0.9,
31049                match_type: "exact_name".to_string(),
31050                tagpath_handle: None,
31051            },
31052        ];
31053
31054        let filtered = apply_search_facet_filters(
31055            dir.path(),
31056            hits,
31057            &SearchFacetFilters {
31058                languages: vec!["rust".to_string()],
31059                kinds: vec!["function".to_string()],
31060                node_kinds: vec!["function_item".to_string()],
31061                ..SearchFacetFilters::default()
31062            },
31063        );
31064
31065        assert_eq!(filtered.len(), 1);
31066        assert_eq!(filtered[0].name, "alpha_helper");
31067    }
31068
31069    #[test]
31070    fn search_facet_filters_match_markdown_sections_and_block_metadata() {
31071        let dir = markdown_search_facet_fixture();
31072
31073        let nested_list = apply_search_facet_filters(
31074            dir.path(),
31075            markdown_search_facet_hits(dir.path(), "setup"),
31076            &SearchFacetFilters {
31077                sections: vec!["Install".to_string()],
31078                parents: vec!["Run setup.".to_string()],
31079                list_depths: vec![1],
31080                ..SearchFacetFilters::default()
31081            },
31082        );
31083        assert_eq!(nested_list.len(), 1);
31084        assert_eq!(nested_list[0].name, "Confirm setup.");
31085
31086        let parent_list = apply_search_facet_filters(
31087            dir.path(),
31088            markdown_search_facet_hits(dir.path(), "setup"),
31089            &SearchFacetFilters {
31090                children: vec!["Confirm setup.".to_string()],
31091                ..SearchFacetFilters::default()
31092            },
31093        );
31094        assert_eq!(parent_list.len(), 1);
31095        assert_eq!(parent_list[0].name, "Run setup.");
31096
31097        let heading = apply_search_facet_filters(
31098            dir.path(),
31099            markdown_search_facet_hits(dir.path(), "Install"),
31100            &SearchFacetFilters {
31101                heading_levels: vec![2],
31102                node_kinds: vec!["atx_heading".to_string()],
31103                ..SearchFacetFilters::default()
31104            },
31105        );
31106        assert_eq!(heading.len(), 1);
31107        assert_eq!(heading[0].name, "Install");
31108
31109        let fence = apply_search_facet_filters(
31110            dir.path(),
31111            markdown_search_facet_hits(dir.path(), "rust"),
31112            &SearchFacetFilters {
31113                fence_languages: vec!["rust".to_string()],
31114                kinds: vec!["code_block".to_string()],
31115                ..SearchFacetFilters::default()
31116            },
31117        );
31118        assert_eq!(fence.len(), 1);
31119        assert_eq!(fence[0].kind, "code_block");
31120
31121        let embedded_child = apply_search_facet_filters(
31122            dir.path(),
31123            markdown_search_facet_hits(dir.path(), "rust"),
31124            &SearchFacetFilters {
31125                children: vec!["sample".to_string()],
31126                kinds: vec!["code_block".to_string()],
31127                ..SearchFacetFilters::default()
31128            },
31129        );
31130        assert_eq!(embedded_child.len(), 1);
31131        assert_eq!(embedded_child[0].name, "rust");
31132    }
31133
31134    #[test]
31135    fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
31136        let response = empty_search_response(Path::new("/repo"), "lexical");
31137        let symbol_hits = vec![
31138            index::SymbolHit {
31139                name: "alpha_helper".to_string(),
31140                kind: "function".to_string(),
31141                language: "rust".to_string(),
31142                file: "/repo/src/lib.rs".to_string(),
31143                line: 12,
31144                end_line: None,
31145                node_kind: None,
31146                start_byte: None,
31147                end_byte: None,
31148                body_start_byte: None,
31149                body_end_byte: None,
31150                tags: Some("alpha,helper".to_string()),
31151                score: 0.98,
31152                match_type: "exact_name".to_string(),
31153                tagpath_handle: None,
31154            },
31155            index::SymbolHit {
31156                name: "alphaHelper".to_string(),
31157                kind: "method".to_string(),
31158                language: "rust".to_string(),
31159                file: "/repo/src/main.rs".to_string(),
31160                line: 34,
31161                end_line: None,
31162                node_kind: None,
31163                start_byte: None,
31164                end_byte: None,
31165                body_start_byte: None,
31166                body_end_byte: None,
31167                tags: Some("alpha,helper".to_string()),
31168                score: 0.93,
31169                match_type: "tag_overlap".to_string(),
31170                tagpath_handle: None,
31171            },
31172            index::SymbolHit {
31173                name: "alpha_helper".to_string(),
31174                kind: "function".to_string(),
31175                language: "rust".to_string(),
31176                file: "/repo/src/worker.rs".to_string(),
31177                line: 56,
31178                end_line: None,
31179                node_kind: None,
31180                start_byte: None,
31181                end_byte: None,
31182                body_start_byte: None,
31183                body_end_byte: None,
31184                tags: Some("alpha,helper".to_string()),
31185                score: 0.91,
31186                match_type: "tag_overlap".to_string(),
31187                tagpath_handle: None,
31188            },
31189        ];
31190
31191        let report = build_relative_search_budget_report(
31192            "alpha helper",
31193            "lexical",
31194            Path::new("/repo"),
31195            &response,
31196            &symbol_hits,
31197            ResponseBudget::new(Some(5), Some(48)),
31198            &SearchFacetFilters::default(),
31199        );
31200
31201        assert_eq!(report.symbol_total, 1);
31202        assert_eq!(report.raw_symbol_total, 3);
31203        assert_eq!(report.symbols.len(), 1);
31204        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
31205        assert_eq!(report.symbols[0].match_count, 3);
31206        assert_eq!(report.symbols[0].surface_count, 2);
31207        assert_eq!(report.symbols[0].file_count, 3);
31208        assert_eq!(
31209            report.symbols[0].surface_examples,
31210            vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
31211        );
31212        assert!(report.symbols[0].name.contains("(+1 variant)"));
31213        assert!(report.symbols[0].file.contains("(+2 files)"));
31214        assert!(report.symbols[0].expand.contains("tsift search"));
31215        assert!(report.symbols[0].expand.contains("alpha helper"));
31216    }
31217
31218    #[test]
31219    fn search_budget_report_carries_active_filters() {
31220        let response = empty_search_response(Path::new("/repo"), "lexical");
31221        let symbol_hits = vec![index::SymbolHit {
31222            name: "alpha_helper".to_string(),
31223            kind: "function".to_string(),
31224            language: "rust".to_string(),
31225            file: "/repo/src/lib.rs".to_string(),
31226            line: 12,
31227            end_line: None,
31228            node_kind: Some("function_item".to_string()),
31229            start_byte: None,
31230            end_byte: None,
31231            body_start_byte: None,
31232            body_end_byte: None,
31233            tags: Some("alpha,helper".to_string()),
31234            score: 0.98,
31235            match_type: "exact_name".to_string(),
31236            tagpath_handle: None,
31237        }];
31238        let filters = SearchFacetFilters {
31239            languages: vec!["rust".to_string()],
31240            kinds: vec!["function".to_string()],
31241            node_kinds: vec!["function_item".to_string()],
31242            ..SearchFacetFilters::default()
31243        };
31244
31245        let report = build_relative_search_budget_report(
31246            "alpha helper",
31247            "lexical",
31248            Path::new("/repo"),
31249            &response,
31250            &symbol_hits,
31251            ResponseBudget::new(Some(5), Some(48)),
31252            &filters,
31253        );
31254
31255        assert_eq!(report.filters, filters);
31256        assert_eq!(
31257            search_facet_filters_summary(&report.filters),
31258            "lang=rust kind=function node-kind=function_item"
31259        );
31260    }
31261
31262    #[test]
31263    fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
31264        let mut response = empty_search_response(Path::new("/repo"), "lexical");
31265        response.indexed_artifacts = 450;
31266        let symbol_hits = vec![
31267            index::SymbolHit {
31268                name: "alpha_helper".to_string(),
31269                kind: "function".to_string(),
31270                language: "rust".to_string(),
31271                file: "/repo/src/lib.rs".to_string(),
31272                line: 12,
31273                end_line: None,
31274                node_kind: None,
31275                start_byte: None,
31276                end_byte: None,
31277                body_start_byte: None,
31278                body_end_byte: None,
31279                tags: Some("alpha,helper".to_string()),
31280                score: 0.98,
31281                match_type: "exact_name".to_string(),
31282                tagpath_handle: None,
31283            },
31284            index::SymbolHit {
31285                name: "beta_helper".to_string(),
31286                kind: "function".to_string(),
31287                language: "rust".to_string(),
31288                file: "/repo/src/beta.rs".to_string(),
31289                line: 21,
31290                end_line: None,
31291                node_kind: None,
31292                start_byte: None,
31293                end_byte: None,
31294                body_start_byte: None,
31295                body_end_byte: None,
31296                tags: Some("beta,helper".to_string()),
31297                score: 0.92,
31298                match_type: "tag_overlap".to_string(),
31299                tagpath_handle: None,
31300            },
31301        ];
31302
31303        let report = build_relative_search_budget_report(
31304            "helper",
31305            "lexical",
31306            Path::new("/repo"),
31307            &response,
31308            &symbol_hits,
31309            ResponseBudget::new(Some(1), Some(64)),
31310            &SearchFacetFilters::default(),
31311        );
31312
31313        let guard = report
31314            .scale_guard
31315            .as_ref()
31316            .expect("broad previews should emit a scale guard");
31317        assert_eq!(guard.level, "high-hit");
31318        assert_eq!(guard.signals.indexed_artifacts, 450);
31319        assert_eq!(guard.signals.raw_symbol_matches, 2);
31320        assert!(
31321            guard
31322                .narrow_commands
31323                .iter()
31324                .any(|command| command.contains("--exact"))
31325        );
31326        assert!(
31327            guard
31328                .narrow_commands
31329                .iter()
31330                .any(|command| command.contains("alpha helper"))
31331        );
31332        assert!(
31333            guard
31334                .narrow_commands
31335                .last()
31336                .unwrap()
31337                .contains("workflow search")
31338        );
31339    }
31340
31341    #[test]
31342    fn explain_budget_report_limits_edges_and_members() {
31343        let symbols = vec![index::StoredSymbol {
31344            name: "alpha_helper".to_string(),
31345            kind: "function".to_string(),
31346            language: "rust".to_string(),
31347            signature: None,
31348            file: "src/lib.rs".to_string(),
31349            line: 10,
31350            end_line: None,
31351            node_kind: None,
31352            start_byte: None,
31353            end_byte: None,
31354            body_start_byte: None,
31355            body_end_byte: None,
31356            parent_module: None,
31357            visibility: None,
31358            tags: None,
31359            tagpath_handle: None,
31360        }];
31361        let callers = vec![
31362            index::StoredEdge {
31363                caller_file: "src/main.rs".to_string(),
31364                caller_name: "main".to_string(),
31365                caller_line: 1,
31366                callee_name: "alpha_helper".to_string(),
31367                call_site_line: 3,
31368                tagpath_handle: None,
31369            },
31370            index::StoredEdge {
31371                caller_file: "src/worker.rs".to_string(),
31372                caller_name: "worker".to_string(),
31373                caller_line: 5,
31374                callee_name: "alpha_helper".to_string(),
31375                call_site_line: 8,
31376                tagpath_handle: None,
31377            },
31378        ];
31379        let community = graph::Community {
31380            id: 1,
31381            members: vec![
31382                graph::CommunityMember::new("alpha_helper"),
31383                graph::CommunityMember::new("main"),
31384                graph::CommunityMember::new("worker"),
31385            ],
31386            modularity_contribution: 0.5,
31387        };
31388
31389        let report = build_explain_budget_report(
31390            "alpha_helper",
31391            Path::new("/repo"),
31392            &symbols,
31393            &callers,
31394            2,
31395            false,
31396            &[],
31397            0,
31398            false,
31399            Some(&community),
31400            ResponseBudget::new(Some(1), Some(24)),
31401        );
31402
31403        assert_eq!(report.definitions.len(), 1);
31404        assert_eq!(report.callers.len(), 1);
31405        assert!(report.truncated);
31406        assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
31407        assert_eq!(
31408            report.definitions[0].tag_alias.as_deref(),
31409            Some("alpha/helper")
31410        );
31411        assert!(report.callers[0].handle.starts_with("ecall-"));
31412        assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
31413    }
31414
31415    #[test]
31416    fn session_review_next_context_budget_limits_lists() {
31417        let report = session_review::SessionReviewReport {
31418            root: "/repo".to_string(),
31419            target: "tasks/software/tsift.md".to_string(),
31420            target_kind: "file".to_string(),
31421            sessions_considered: 1,
31422            sessions_matched: 1,
31423            claude_sessions: 1,
31424            codex_sessions: 0,
31425            agent_doc_logs: 0,
31426            prompt_target_count: 2,
31427            command_groups: 0,
31428            file_groups: 2,
31429            symbol_groups: 1,
31430            failure_groups: 1,
31431            runtime_event_groups: 0,
31432            restart_churn_groups: 0,
31433            closeout_groups: 0,
31434            usage_samples: 1,
31435            prompt_tokens: 120,
31436            cached_input_tokens: 80,
31437            cache_creation_input_tokens: 0,
31438            output_tokens: 40,
31439            reasoning_output_tokens: 0,
31440            total_tokens: 240,
31441            cached_input_ratio: Some(40.0),
31442            largest_turn_total_tokens: 240,
31443            aggregate_cost: session_review::SessionReviewCostSummary {
31444                scope: "bounded_matched_sessions".to_string(),
31445                sessions: 1,
31446                usage_samples: 1,
31447                prompt_tokens: 120,
31448                cached_input_tokens: 80,
31449                cache_creation_input_tokens: 0,
31450                output_tokens: 40,
31451                reasoning_output_tokens: 0,
31452                total_tokens: 240,
31453                cached_input_ratio: Some(40.0),
31454                largest_turn_total_tokens: 240,
31455            },
31456            latest_session_cost: Some(session_review::SessionReviewCostSummary {
31457                scope: "latest_matched_session".to_string(),
31458                sessions: 1,
31459                usage_samples: 1,
31460                prompt_tokens: 120,
31461                cached_input_tokens: 80,
31462                cache_creation_input_tokens: 0,
31463                output_tokens: 40,
31464                reasoning_output_tokens: 0,
31465                total_tokens: 240,
31466                cached_input_ratio: Some(66.67),
31467                largest_turn_total_tokens: 240,
31468            }),
31469            prompt_cache_cross_run: None,
31470            prompt_cache_roi_scorecard: vec![],
31471            guardrails: vec![
31472                session_cost::SessionCostGuardrail {
31473                    kind: "cache_resend".to_string(),
31474                    severity: "warn".to_string(),
31475                    message: "cached input ratio was high".to_string(),
31476                    guidance: "compact or restart the session".to_string(),
31477                },
31478                session_cost::SessionCostGuardrail {
31479                    kind: "prompt_budget".to_string(),
31480                    severity: "warn".to_string(),
31481                    message: "largest prompt turn reached 999999 tokens".to_string(),
31482                    guidance: "compact the session before another large turn".to_string(),
31483                },
31484                session_cost::SessionCostGuardrail {
31485                    kind: "restart_loop".to_string(),
31486                    severity: "warn".to_string(),
31487                    message: "restart churn detected".to_string(),
31488                    guidance: "restart cleanly".to_string(),
31489                },
31490                session_cost::SessionCostGuardrail {
31491                    kind: "noop_closeout".to_string(),
31492                    severity: "warn".to_string(),
31493                    message: "commit_already_current appeared 8 times".to_string(),
31494                    guidance: "avoid reopening without new edits".to_string(),
31495                },
31496            ],
31497            loop_clusters: vec![session_cost::SessionCostLoopCluster {
31498                kind: "command_bundle".to_string(),
31499                label: "cargo test -> cargo build --release".to_string(),
31500                occurrences: 2,
31501                max_consecutive: 2,
31502            }],
31503            file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
31504                path: "src/lib.rs".to_string(),
31505                range: "12-40".to_string(),
31506                occurrences: 3,
31507                estimated_tokens: 1200,
31508                duplicate_estimated_tokens: 800,
31509                follow_up_commands: vec![
31510                    "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
31511                        .to_string(),
31512                ],
31513            }],
31514            prompt_targets: vec![
31515                session_review::SessionReviewPromptTarget {
31516                    text: "do one".to_string(),
31517                    occurrences: 1,
31518                },
31519                session_review::SessionReviewPromptTarget {
31520                    text: "do two".to_string(),
31521                    occurrences: 1,
31522                },
31523            ],
31524            commands: vec![],
31525            touched_files: vec![],
31526            touched_symbols: vec![],
31527            failures: vec![],
31528            runtime_events: vec![],
31529            restart_churn: vec![],
31530            closeout: vec![],
31531            largest_turns: vec![],
31532            sessions: vec![session_review::SessionReviewSession {
31533                source: "claude_jsonl".to_string(),
31534                path: "/tmp/session.jsonl".to_string(),
31535                matched_by: vec!["path".to_string()],
31536                modified_unix_secs: None,
31537                prompt_target_count: 2,
31538                command_groups: 0,
31539                file_groups: 2,
31540                symbol_groups: 1,
31541                failure_groups: 1,
31542                runtime_event_groups: 0,
31543                restart_churn_groups: 0,
31544                closeout_groups: 0,
31545                usage_samples: 1,
31546                prompt_tokens: 120,
31547                cached_input_tokens: 80,
31548                cache_creation_input_tokens: 0,
31549                output_tokens: 40,
31550                reasoning_output_tokens: 0,
31551                total_tokens: 240,
31552                largest_turn_total_tokens: 240,
31553            }],
31554            next_context: session_review::SessionReviewNextContext {
31555                target: "tasks/software/tsift.md".to_string(),
31556                active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
31557                last_verification: session_review::SessionReviewVerificationState {
31558                    status: "green".to_string(),
31559                    detail: "cargo test".to_string(),
31560                },
31561                touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
31562                touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
31563                unresolved_failures: vec![session_review::SessionReviewFailure {
31564                    kind: "timeout".to_string(),
31565                    message: "search timed out".to_string(),
31566                    occurrences: 1,
31567                    command: None,
31568                    session_path: None,
31569                }],
31570                agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
31571                    active_queue_prompt: Some(
31572                        "[#one] do one with enough detail to truncate".to_string(),
31573                    ),
31574                    live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
31575                    backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
31576                    review_rows: vec![
31577                        "[#review] review one".to_string(),
31578                        "[#review2] review two".to_string(),
31579                    ],
31580                    prompt_presets: vec![
31581                        "#spec-test-build-install-commit-push: update spec + tests"
31582                            .to_string(),
31583                        "#next-steps: collect follow-ups".to_string(),
31584                    ],
31585                    expansion_handles: vec![
31586                        session_review::SessionReviewAgentDocExpansionHandle {
31587                            handle: "adq-next-context".to_string(),
31588                            label: "refresh next-context".to_string(),
31589                            expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
31590                        },
31591                        session_review::SessionReviewAgentDocExpansionHandle {
31592                            handle: "adq-context-pack".to_string(),
31593                            label: "refresh context-pack".to_string(),
31594                            expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
31595                        },
31596                    ],
31597                }),
31598                prompt_cache_health: None,
31599                next_digest_commands: vec![
31600                    "tsift session-review --next-context tasks/software/tsift.md".to_string(),
31601                    "tsift diff-digest .".to_string(),
31602                    "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
31603                    "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
31604                ],
31605            },
31606            warnings: vec![],
31607        };
31608
31609        let budget_report = build_session_review_next_context_budget_report(
31610            &report,
31611            ResponseBudget::new(Some(1), Some(12)),
31612            None,
31613        );
31614
31615        assert!(budget_report.truncated);
31616        assert_eq!(budget_report.prompt_targets, vec!["do one"]);
31617        assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
31618        assert!(
31619            budget_report.touched_symbol_refs[0]
31620                .handle
31621                .starts_with("ncsym-")
31622        );
31623        assert_eq!(
31624            budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
31625            Some("alpha/helper")
31626        );
31627        assert!(
31628            budget_report.unresolved_failures[0]
31629                .handle
31630                .starts_with("snf-")
31631        );
31632        assert_eq!(budget_report.next_digest_commands.len(), 4);
31633        assert_eq!(
31634            budget_report.next_digest_commands[2],
31635            "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
31636        );
31637        let queue = budget_report
31638            .agent_doc_queue
31639            .as_ref()
31640            .expect("agent-doc queue budget profile should be present");
31641        assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
31642        assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
31643        assert_eq!(queue.review_row_total, 2);
31644        assert_eq!(queue.prompt_presets.len(), 1);
31645        assert_eq!(queue.expansion_handles.len(), 2);
31646        assert!(queue.truncated);
31647        assert_eq!(budget_report.next_token_actions.len(), 1);
31648        assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
31649
31650        let full_action_report = build_session_review_next_context_budget_report(
31651            &report,
31652            ResponseBudget::new(Some(6), Some(120)),
31653            None,
31654        );
31655        assert_eq!(
31656            full_action_report
31657                .next_token_actions
31658                .iter()
31659                .map(|action| action.kind.as_str())
31660                .collect::<Vec<_>>(),
31661            vec![
31662                "prompt_budget",
31663                "cache_resend",
31664                "repeated_raw_read",
31665                "repeated_command_bundle",
31666                "restart_loop",
31667                "noop_closeout"
31668            ]
31669        );
31670        assert_eq!(
31671            full_action_report.next_token_actions[0]
31672                .compact_command
31673                .as_deref(),
31674            Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
31675        );
31676        assert_eq!(
31677            full_action_report.next_token_actions[0]
31678                .restart_command
31679                .as_deref(),
31680            Some("agent-doc start \"tasks/software/tsift.md\"")
31681        );
31682        assert!(
31683            full_action_report.next_token_actions[0]
31684                .digest_commands
31685                .iter()
31686                .any(|command| command
31687                    == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
31688        );
31689        let raw_read_action = full_action_report
31690            .next_token_actions
31691            .iter()
31692            .find(|action| action.kind == "repeated_raw_read")
31693            .expect("raw read action");
31694        assert!(
31695            raw_read_action.rewrite_commands.iter().any(
31696                |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
31697            ),
31698            "raw read rewrite commands: {:?}",
31699            raw_read_action.rewrite_commands
31700        );
31701        assert!(raw_read_action.rewrite_commands.iter().any(|command| command
31702        == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
31703        let command_bundle_action = full_action_report
31704            .next_token_actions
31705            .iter()
31706            .find(|action| action.kind == "repeated_command_bundle")
31707            .expect("command bundle action");
31708        assert!(
31709            command_bundle_action
31710                .rewrite_commands
31711                .iter()
31712                .any(|command| command == "tsift rewrite --run \"cargo test\"")
31713        );
31714        assert!(
31715            command_bundle_action
31716                .rewrite_commands
31717                .iter()
31718                .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
31719        );
31720    }
31721
31722    #[test]
31723    fn context_pack_diff_preview_limits_files_and_symbols() {
31724        let report = diff_digest::DiffDigestReport {
31725            root: "/repo".to_string(),
31726            mode: diff_digest::DiffDigestMode::WorkingTree,
31727            revision: None,
31728            files_changed: 2,
31729            files_with_current_summaries: 1,
31730            symbols_touched: 3,
31731            call_edges_added: 1,
31732            call_edges_removed: 0,
31733            files: vec![
31734                diff_digest::DiffDigestFile {
31735                    path: "src/lib.rs".to_string(),
31736                    status: diff_digest::DiffDigestFileStatus::Modified,
31737                    touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
31738                    summary_state: diff_digest::DiffDigestSummaryState::Current,
31739                    current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31740                        symbol: "alpha_helper".to_string(),
31741                        summary: "alpha helper handles the main alpha workflow".to_string(),
31742                    }],
31743                    added_call_edges: vec!["alpha->beta".to_string()],
31744                    removed_call_edges: vec![],
31745                    warnings: vec!["stale parse".to_string()],
31746                },
31747                diff_digest::DiffDigestFile {
31748                    path: "src/main.rs".to_string(),
31749                    status: diff_digest::DiffDigestFileStatus::Added,
31750                    touched_symbols: vec!["main".to_string()],
31751                    summary_state: diff_digest::DiffDigestSummaryState::Missing,
31752                    current_summaries: vec![],
31753                    added_call_edges: vec![],
31754                    removed_call_edges: vec![],
31755                    warnings: vec![],
31756                },
31757            ],
31758        };
31759
31760        let preview =
31761            build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
31762
31763        assert!(preview.truncated);
31764        assert_eq!(preview.files.len(), 1);
31765        assert_eq!(preview.files[0].path, "src/lib.rs");
31766        assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
31767        assert!(
31768            preview.files[0].touched_symbol_refs[0]
31769                .handle
31770                .starts_with("cdsym-")
31771        );
31772        assert_eq!(
31773            preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
31774            Some("alpha/he...")
31775        );
31776        assert!(
31777            preview.files[0].summary_refs[0]
31778                .handle
31779                .starts_with("cdsum-")
31780        );
31781        assert_eq!(
31782            preview.files[0].summary_refs[0].tag_alias.as_deref(),
31783            Some("alpha/he...")
31784        );
31785        assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
31786        assert_eq!(
31787            preview.files[0].summary_refs[0].expand,
31788            "tsift summarize --file \"src/lib.rs\""
31789        );
31790        assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
31791    }
31792
31793    #[test]
31794    fn context_pack_status_reminders_include_stale_index_state() {
31795        let dir = setup_graph_index();
31796        std::thread::sleep(std::time::Duration::from_millis(50));
31797        std::fs::write(
31798            dir.path().join("main.rs"),
31799            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
31800        )
31801        .unwrap();
31802
31803        let reminders = context_pack_status_reminders(dir.path());
31804
31805        assert_eq!(reminders.len(), 1);
31806        assert!(reminders[0].contains("index stale"));
31807        assert!(reminders[0].contains("tsift index ."));
31808    }
31809
31810    // #gdbgatecold regression-lock: the trusted context-pack pipeline must
31811    // share its index-inspection across `prepare_agent_doc_index_gate` and
31812    // `context_pack_status_reminders` (both call `IndexDb::inspect_read_only`
31813    // on the same `(root, .tsift/index.db)` key). With the scope guard
31814    // active in `build_context_pack_report_with_profile`, the second call
31815    // hits the cache, so we should record one miss and at least one hit.
31816    #[test]
31817    fn build_context_pack_reuses_inspect_within_scope() {
31818        let dir = setup_graph_index();
31819        init_git_repo(dir.path());
31820        let _guard = index::InspectScopeGuard::new();
31821        let _ = build_context_pack_report(
31822            dir.path(),
31823            None,
31824            None,
31825            None,
31826            ResponseBudget::new(Some(2), Some(96)),
31827        )
31828        .unwrap();
31829        let (hits, misses) = index::inspect_scope_stats();
31830        assert!(
31831            hits >= 1,
31832            "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
31833        );
31834        assert!(
31835            misses >= 1,
31836            "expected at least one initial inspect miss (hits={hits}, misses={misses})"
31837        );
31838    }
31839
31840    // #gdbgatecold scope-isolation: outside of any scope, every call to
31841    // `IndexDb::inspect_read_only` must hit the disk fresh. This locks in
31842    // the contract that the search/status fast-paths never reuse a cached
31843    // inspection across consecutive top-level calls.
31844    #[test]
31845    fn inspect_read_only_outside_scope_does_not_cache() {
31846        let dir = setup_graph_index();
31847        let db_path = dir.path().join(".tsift/index.db");
31848        let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31849        let (hits, misses) = index::inspect_scope_stats();
31850        assert_eq!(
31851            (hits, misses),
31852            (0, 0),
31853            "no scope guard => no hits/misses recorded"
31854        );
31855        let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31856        let (hits, _) = index::inspect_scope_stats();
31857        assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
31858    }
31859
31860    #[test]
31861    fn context_pack_refreshes_stale_index_before_handoff() {
31862        let dir = setup_graph_index();
31863        init_git_repo(dir.path());
31864        std::thread::sleep(std::time::Duration::from_millis(50));
31865        std::fs::write(
31866            dir.path().join("main.rs"),
31867            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
31868        )
31869        .unwrap();
31870
31871        let report = build_context_pack_report(
31872            dir.path(),
31873            None,
31874            None,
31875            None,
31876            ResponseBudget::new(Some(2), Some(96)),
31877        )
31878        .unwrap();
31879
31880        assert!(
31881            report
31882                .status_reminders
31883                .iter()
31884                .any(|reminder| reminder.contains("index refreshed")
31885                    && reminder.contains("context-pack handoff")),
31886            "expected context-pack refresh diagnostic, got {:?}",
31887            report.status_reminders
31888        );
31889        assert!(
31890            !report
31891                .status_reminders
31892                .iter()
31893                .any(|reminder| reminder.contains("index stale")),
31894            "stale reminder should be gone after refresh: {:?}",
31895            report.status_reminders
31896        );
31897
31898        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
31899        let summary = db.compute_changes(dir.path()).unwrap();
31900        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
31901    }
31902
31903    #[test]
31904    fn context_pack_materializes_source_handles_into_graph_store() {
31905        let dir = tempfile::tempdir().unwrap();
31906        let packet = ExplorationPacket {
31907            budget: exploration_budget_for_counts(2, 1),
31908            relationship_map: vec![ExplorationRelation {
31909                from: "file:main.rs".to_string(),
31910                relation: "touches_symbol".to_string(),
31911                to: "symbol:helper".to_string(),
31912                label: Some("modified diff".to_string()),
31913            }],
31914            source_windows: vec![ExplorationSourceWindow {
31915                handle: "xwin-test".to_string(),
31916                file: "main.rs".to_string(),
31917                start: 1,
31918                end: 32,
31919                reason: "changed file".to_string(),
31920                expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
31921            }],
31922            worker_context: vec![ExplorationWorkerContext {
31923                handle: "xwrk-test".to_string(),
31924                target: "tasks/software/tsift.md".to_string(),
31925                summary: "do #kgnv".to_string(),
31926                expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
31927                    .to_string(),
31928            }],
31929            no_reread_guidance: "use windows".to_string(),
31930        };
31931
31932        let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
31933        assert_eq!(packet.source_windows[0].handle, "xwin-test");
31934
31935        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
31936        let source_handles = store.nodes_by_kind("source_handle").unwrap();
31937        assert_eq!(source_handles.len(), 1);
31938        assert_eq!(
31939            source_handles[0].properties.get("file"),
31940            Some(&"main.rs".to_string())
31941        );
31942        assert_eq!(
31943            store
31944                .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
31945                .unwrap()
31946                .len(),
31947            1
31948        );
31949        let worker_context = store.nodes_by_kind("worker_context").unwrap();
31950        assert_eq!(worker_context.len(), 1);
31951        assert_eq!(
31952            store
31953                .outgoing_edges("xwrk-test", Some("scopes_source"))
31954                .unwrap()
31955                .len(),
31956            1
31957        );
31958    }
31959
31960    #[test]
31961    fn context_pack_records_graph_orchestration_observability() {
31962        let dir = setup_traversal_project();
31963        init_git_repo(dir.path());
31964        let session = dir.path().join("tasks/software/tsift.md");
31965        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
31966
31967        let report = build_context_pack_report(
31968            &session,
31969            None,
31970            None,
31971            None,
31972            ResponseBudget::new(Some(4), Some(160)),
31973        )
31974        .unwrap();
31975
31976        assert_eq!(
31977            report.graph_orchestration.contract_version,
31978            CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
31979        );
31980        assert_eq!(
31981            report
31982                .graph_orchestration
31983                .projection_freshness
31984                .status
31985                .as_str(),
31986            "current"
31987        );
31988        assert!(!report.graph_orchestration.projection_hashes.is_empty());
31989        assert_eq!(report.graph_orchestration.readiness.status, "blocked");
31990        assert_eq!(
31991            report.graph_orchestration.readiness.reason,
31992            "summary_cache_empty"
31993        );
31994        assert!(report.graph_orchestration.readiness.fail_closed);
31995        assert!(
31996            report
31997                .graph_orchestration
31998                .readiness
31999                .next_commands
32000                .iter()
32001                .any(|command| command == "tsift summarize --extract ."),
32002            "{:?}",
32003            report.graph_orchestration.readiness.next_commands
32004        );
32005        assert!(
32006            report
32007                .graph_orchestration
32008                .evidence_packet_ids
32009                .iter()
32010                .all(|id| !id.starts_with("gevd-")),
32011            "evidence packet ids should be empty when readiness is blocked: {:?}",
32012            report.graph_orchestration.evidence_packet_ids
32013        );
32014        assert!(
32015            report
32016                .graph_orchestration
32017                .conflict_matrix_decisions
32018                .iter()
32019                .any(|decision| decision.contains("readiness blocked")),
32020            "conflict-matrix decisions should reference readiness block: {:?}",
32021            report.graph_orchestration.conflict_matrix_decisions
32022        );
32023        assert!(
32024            !report
32025                .graph_orchestration
32026                .follow_up_commands
32027                .iter()
32028                .any(|command| command.contains("conflict-matrix")),
32029            "conflict-matrix command should not appear when readiness is blocked: {:?}",
32030            report.graph_orchestration.follow_up_commands
32031        );
32032        assert!(
32033            report
32034                .graph_orchestration
32035                .follow_up_commands
32036                .iter()
32037                .any(|command| command == "tsift summarize --extract ."),
32038            "{:?}",
32039            report.graph_orchestration.follow_up_commands
32040        );
32041        assert!(
32042            !report
32043                .graph_orchestration
32044                .worker_ownership_blocks
32045                .is_empty()
32046        );
32047    }
32048
32049    #[test]
32050    fn convex_sync_report_chunks_upserts_and_tombstones() {
32051        let dir = setup_traversal_project();
32052        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
32053        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
32054        let mut snapshot = projection.to_convex_rows();
32055        snapshot.nodes.push(ConvexNodeRow {
32056            external_id: "stale-node".to_string(),
32057            kind: "backlog".to_string(),
32058            label: "stale".to_string(),
32059            properties: BTreeMap::new(),
32060            provenance: Vec::new(),
32061            freshness: None,
32062        });
32063        snapshot.edges.clear();
32064        snapshot.edges.push(ConvexEdgeRow {
32065            edge_key: "stale-edge".to_string(),
32066            from_external_id: "stale-node".to_string(),
32067            to_external_id: "stale-node".to_string(),
32068            kind: "mentions".to_string(),
32069            properties: BTreeMap::new(),
32070            provenance: Vec::new(),
32071            freshness: None,
32072        });
32073        let snapshot_path = dir.path().join("convex-snapshot.json");
32074        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
32075
32076        let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
32077
32078        assert_eq!(report.freshness.status, "stale");
32079        assert!(report.freshness.fail_closed);
32080        assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
32081        assert!(
32082            report.edge_upserts.len() > 1,
32083            "snapshot without edges should upsert local edges"
32084        );
32085        assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
32086        assert_eq!(
32087            report.chunks.first().map(|chunk| chunk.operation.as_str()),
32088            Some("delete_edges"),
32089            "edge tombstones should be planned before node tombstones"
32090        );
32091        assert!(
32092            report
32093                .chunks
32094                .iter()
32095                .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
32096            "expected chunked edge upserts, got {:?}",
32097            report.chunks
32098        );
32099    }
32100
32101    #[test]
32102    fn convex_snapshot_validation_fails_closed_when_stale() {
32103        let dir = setup_traversal_project();
32104        build_traversal_graph(dir.path(), dir.path(), None).unwrap();
32105        let snapshot = ConvexProjectionRows::default();
32106        let snapshot_path = dir.path().join("empty-convex-snapshot.json");
32107        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
32108
32109        let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
32110        assert!(
32111            err.to_string()
32112                .contains("Convex graph projection is not current"),
32113            "{err}"
32114        );
32115    }
32116
32117    #[test]
32118    fn convex_sync_report_marks_live_apply_mode_without_network() {
32119        let dir = setup_traversal_project();
32120        let report =
32121            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
32122
32123        assert!(!report.dry_run);
32124        assert!(
32125            !report
32126                .diagnostics
32127                .iter()
32128                .any(|diagnostic| diagnostic.contains("dry-run only")),
32129            "apply-mode report should not claim dry-run diagnostics"
32130        );
32131        assert!(
32132            report
32133                .chunks
32134                .iter()
32135                .any(|chunk| chunk.operation == "upsert_nodes"),
32136            "live apply mode should still expose chunked idempotent operations"
32137        );
32138    }
32139
32140    #[test]
32141    fn convex_sync_apply_round_trips_with_http_backend() {
32142        use std::net::TcpListener;
32143        use std::sync::{Arc, Mutex};
32144
32145        let dir = setup_traversal_project();
32146        let report =
32147            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
32148        let expected_chunks = report.chunks.len();
32149        assert!(expected_chunks > 0);
32150
32151        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
32152        let endpoint = format!("http://{}", listener.local_addr().unwrap());
32153        let operations = Arc::new(Mutex::new(Vec::<String>::new()));
32154        let server_operations = Arc::clone(&operations);
32155        let server = std::thread::spawn(move || {
32156            for _ in 0..expected_chunks {
32157                let (mut stream, _) = listener.accept().unwrap();
32158                let mut reader = BufReader::new(stream.try_clone().unwrap());
32159                let mut request_line = String::new();
32160                reader.read_line(&mut request_line).unwrap();
32161                assert!(request_line.starts_with("POST "));
32162
32163                let mut content_length = 0usize;
32164                loop {
32165                    let mut line = String::new();
32166                    reader.read_line(&mut line).unwrap();
32167                    if line == "\r\n" {
32168                        break;
32169                    }
32170                    if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
32171                        content_length = value.trim().parse().unwrap();
32172                    }
32173                }
32174
32175                let mut body = vec![0u8; content_length];
32176                reader.read_exact(&mut body).unwrap();
32177                let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
32178                server_operations
32179                    .lock()
32180                    .unwrap()
32181                    .push(request["operation"].as_str().unwrap().to_string());
32182
32183                let response = br#"{"status":"ok","message":"accepted"}"#;
32184                write!(
32185                    stream,
32186                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
32187                    response.len()
32188                )
32189                .unwrap();
32190                stream.write_all(response).unwrap();
32191            }
32192        });
32193
32194        cmd_convex_sync(
32195            ConvexSyncOptions {
32196                path: dir.path(),
32197                scope: None,
32198                snapshot: None,
32199                chunk_size: 100,
32200                remote_snapshot: false,
32201                apply: true,
32202                endpoint: Some(&endpoint),
32203                auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
32204            },
32205            OutputFormat {
32206                json_output: false,
32207                compact: true,
32208                pretty: false,
32209                terse: false,
32210                ultra_terse: false,
32211                schema: false,
32212                envelope: false,
32213            },
32214        )
32215        .unwrap();
32216        server.join().unwrap();
32217
32218        let operations = operations.lock().unwrap().clone();
32219        assert!(operations.contains(&"upsert_nodes".to_string()));
32220        assert!(operations.contains(&"upsert_edges".to_string()));
32221    }
32222
32223    #[test]
32224    fn context_pack_diff_preview_attaches_tag_ontology_refs() {
32225        let root = tempfile::tempdir().unwrap();
32226        fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
32227        fs::write(
32228            root.path().join(".naming/tags/alpha.md"),
32229            "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
32230        )
32231        .unwrap();
32232        let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
32233        let report = diff_digest::DiffDigestReport {
32234            root: root.path().display().to_string(),
32235            mode: diff_digest::DiffDigestMode::WorkingTree,
32236            revision: None,
32237            files_changed: 1,
32238            files_with_current_summaries: 1,
32239            symbols_touched: 1,
32240            call_edges_added: 0,
32241            call_edges_removed: 0,
32242            files: vec![diff_digest::DiffDigestFile {
32243                path: "src/lib.rs".to_string(),
32244                status: diff_digest::DiffDigestFileStatus::Modified,
32245                touched_symbols: vec!["alpha_helper".to_string()],
32246                summary_state: diff_digest::DiffDigestSummaryState::Current,
32247                current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
32248                    symbol: "alpha_helper".to_string(),
32249                    summary: "alpha helper summary".to_string(),
32250                }],
32251                added_call_edges: vec![],
32252                removed_call_edges: vec![],
32253                warnings: vec![],
32254            }],
32255        };
32256
32257        let preview = build_context_pack_diff_preview(
32258            &report,
32259            ResponseBudget::new(Some(1), Some(80)),
32260            Some(&ontology),
32261        );
32262
32263        let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
32264        assert!(symbol_ref.handle.starts_with("tont-"));
32265        assert_eq!(symbol_ref.tag, "alpha");
32266        assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
32267        assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
32268        assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
32269        assert_eq!(
32270            preview.files[0].summary_refs[0].ontology_refs[0].path,
32271            ".naming/tags/alpha.md"
32272        );
32273    }
32274
32275    #[test]
32276    fn context_pack_test_preview_limits_failure_groups() {
32277        let report = test_digest::TestDigestReport {
32278            root: "/repo".to_string(),
32279            runner: "cargo".to_string(),
32280            failures: 2,
32281            grouped_failures: 2,
32282            counts: test_digest::TestDigestCounts {
32283                passed: Some(8),
32284                failed: Some(2),
32285                skipped: Some(1),
32286            },
32287            failure_groups: vec![
32288                test_digest::TestDigestFailure {
32289                    tests: vec!["suite::alpha_failure".to_string()],
32290                    message: "assertion failed".to_string(),
32291                    path: Some("src/lib.rs".to_string()),
32292                    line: Some(42),
32293                    column: None,
32294                    occurrences: 1,
32295                    summary_state: test_digest::TestDigestSummaryState::Current,
32296                    current_summaries: vec![test_digest::TestDigestSummarySnippet {
32297                        symbol: "alpha_failure".to_string(),
32298                        summary: "failure summary for alpha test".to_string(),
32299                    }],
32300                },
32301                test_digest::TestDigestFailure {
32302                    tests: vec!["suite::beta_failure".to_string()],
32303                    message: "panic".to_string(),
32304                    path: Some("src/main.rs".to_string()),
32305                    line: Some(7),
32306                    column: None,
32307                    occurrences: 1,
32308                    summary_state: test_digest::TestDigestSummaryState::Missing,
32309                    current_summaries: vec![],
32310                },
32311            ],
32312            warnings: vec!["warning text".to_string()],
32313        };
32314
32315        let preview =
32316            build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32317
32318        assert!(preview.truncated);
32319        assert_eq!(preview.failure_groups.len(), 1);
32320        assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
32321        assert_eq!(preview.failure_groups[0].message, "assertion f...");
32322        assert!(
32323            preview.failure_groups[0].summary_refs[0]
32324                .handle
32325                .starts_with("ctsum-")
32326        );
32327        assert_eq!(
32328            preview.failure_groups[0].summary_refs[0].expand,
32329            "tsift summarize --file \"src/lib.rs\""
32330        );
32331        assert_eq!(preview.warnings, vec!["warning text"]);
32332    }
32333
32334    #[test]
32335    fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
32336        let dir = tempfile::tempdir().unwrap();
32337        let root = dir.path();
32338
32339        // Small log: no artifact attached, nothing written.
32340        let small_input = "Compiling serde v1.0.130\n";
32341        let mut small = log_digest::compute(root, small_input).unwrap();
32342        maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
32343        assert!(small.raw_log_artifact.is_none());
32344        assert!(!root.join(".tsift/artifacts").exists());
32345
32346        // Bulky log: artifact persisted with a stable handle and expand command.
32347        let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
32348        let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
32349        maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
32350        let artifact = bulky
32351            .raw_log_artifact
32352            .expect("artifact attached for bulky log");
32353        assert!(artifact.handle.starts_with("logdg-"));
32354        assert_eq!(artifact.bytes, bulky_input.len());
32355        assert!(artifact.expand.contains("tsift log-digest"));
32356        assert!(artifact.expand.contains("--input"));
32357        let persisted = root.join(&artifact.path);
32358        assert!(persisted.exists(), "artifact file written to {persisted:?}");
32359        assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
32360    }
32361
32362    #[test]
32363    fn context_pack_log_preview_limits_signals_and_refs() {
32364        let report = log_digest::LogDigestReport {
32365            root: "/repo".to_string(),
32366            total_lines: 12,
32367            non_empty_lines: 10,
32368            signal_groups: 2,
32369            error_signal_groups: 1,
32370            repeated_line_groups: 2,
32371            repeated_line_occurrences: 3,
32372            line_family_groups: 0,
32373            file_ref_groups: 2,
32374            symbol_ref_groups: 2,
32375            stack_groups: 1,
32376            signals: vec![
32377                log_digest::LogDigestSignal {
32378                    severity: "error".to_string(),
32379                    message: "src/lib.rs:42 boom".to_string(),
32380                    path: Some("src/lib.rs".to_string()),
32381                    line: Some(42),
32382                    column: None,
32383                    occurrences: 2,
32384                    summary_state: log_digest::LogDigestSummaryState::Current,
32385                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32386                        symbol: "alpha_helper".to_string(),
32387                        summary: "alpha helper cached log summary".to_string(),
32388                    }],
32389                },
32390                log_digest::LogDigestSignal {
32391                    severity: "warn".to_string(),
32392                    message: "slow path".to_string(),
32393                    path: None,
32394                    line: None,
32395                    column: None,
32396                    occurrences: 1,
32397                    summary_state: log_digest::LogDigestSummaryState::Unavailable,
32398                    current_summaries: vec![],
32399                },
32400            ],
32401            repeated_lines: vec![
32402                log_digest::LogDigestRepeatedLine {
32403                    line: "retrying work item alpha".to_string(),
32404                    occurrences: 3,
32405                },
32406                log_digest::LogDigestRepeatedLine {
32407                    line: "retrying work item beta".to_string(),
32408                    occurrences: 2,
32409                },
32410            ],
32411            line_families: vec![],
32412            file_refs: vec![
32413                log_digest::LogDigestFileRef {
32414                    path: "src/lib.rs".to_string(),
32415                    line: Some(42),
32416                    column: None,
32417                    occurrences: 2,
32418                    summary_state: log_digest::LogDigestSummaryState::Current,
32419                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32420                        symbol: "alpha_helper".to_string(),
32421                        summary: "alpha helper cached file summary".to_string(),
32422                    }],
32423                },
32424                log_digest::LogDigestFileRef {
32425                    path: "src/main.rs".to_string(),
32426                    line: Some(7),
32427                    column: None,
32428                    occurrences: 1,
32429                    summary_state: log_digest::LogDigestSummaryState::Missing,
32430                    current_summaries: vec![],
32431                },
32432            ],
32433            symbol_refs: vec![
32434                log_digest::LogDigestSymbolRef {
32435                    symbol: "alpha_helper".to_string(),
32436                    occurrences: 2,
32437                    summary_state: log_digest::LogDigestSummaryState::Current,
32438                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32439                        symbol: "alpha_helper".to_string(),
32440                        summary: "alpha helper cached symbol summary".to_string(),
32441                    }],
32442                },
32443                log_digest::LogDigestSymbolRef {
32444                    symbol: "beta_helper".to_string(),
32445                    occurrences: 1,
32446                    summary_state: log_digest::LogDigestSummaryState::Missing,
32447                    current_summaries: vec![],
32448                },
32449            ],
32450            stack_traces: vec![log_digest::LogDigestStackGroup {
32451                frames: vec!["frame one".to_string()],
32452                occurrences: 1,
32453            }],
32454            raw_log_artifact: None,
32455            warnings: vec!["warning text".to_string()],
32456        };
32457
32458        let preview =
32459            build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32460
32461        assert!(preview.truncated);
32462        assert_eq!(preview.signals.len(), 1);
32463        assert_eq!(preview.signals[0].message, "src/lib.rs:...");
32464        assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
32465        assert_eq!(preview.file_refs.len(), 1);
32466        assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
32467        assert!(
32468            preview.signals[0].summary_refs[0]
32469                .handle
32470                .starts_with("clsum-")
32471        );
32472        assert!(
32473            preview.file_refs[0].summary_refs[0]
32474                .handle
32475                .starts_with("clfsum-")
32476        );
32477        assert!(
32478            preview.symbol_refs[0].summary_refs[0]
32479                .handle
32480                .starts_with("clssum-")
32481        );
32482        assert_eq!(
32483            preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
32484            Some("alpha/helper")
32485        );
32486        assert_eq!(
32487            preview.symbol_refs[0].summary_refs[0].expand,
32488            "tsift summarize \"alpha_helper\""
32489        );
32490        assert_eq!(preview.warnings, vec!["warning text"]);
32491    }
32492
32493    #[test]
32494    fn cli_search_rejects_exact_with_strategy_flag() {
32495        let cli = try_parse_cli([
32496            "tsift",
32497            "search",
32498            "test",
32499            "--exact",
32500            "--strategy",
32501            "lexical",
32502        ]);
32503        assert!(cli.is_err());
32504    }
32505
32506    #[test]
32507    fn cli_search_autoindexes_by_default() {
32508        let cli = parse_cli(["tsift", "search", "test"]);
32509        match cli.command {
32510            Some(Commands::Search {
32511                autoindex,
32512                no_autoindex,
32513                ..
32514            }) => {
32515                assert!(!autoindex);
32516                assert!(!no_autoindex);
32517                assert!(autoindex || !no_autoindex);
32518            }
32519            _ => panic!("expected Search command"),
32520        }
32521    }
32522
32523    #[test]
32524    fn cli_local_model_status_accepts_json_and_no_probe() {
32525        let cli = parse_cli(["tsift", "local-model", "status", "--json", "--no-probe"]);
32526        match cli.command {
32527            Some(Commands::LocalModel {
32528                command: LocalModelCommand::Status { json, no_probe },
32529            }) => {
32530                assert!(json);
32531                assert!(no_probe);
32532            }
32533            _ => panic!("expected LocalModel status command"),
32534        }
32535    }
32536
32537    #[test]
32538    fn cli_local_model_unload_accepts_probe_and_strict_flags() {
32539        let cli = parse_cli([
32540            "tsift",
32541            "local-model",
32542            "unload",
32543            "--profile",
32544            "qwen3-32b-q4",
32545            "--pre-used-mib",
32546            "200",
32547            "--post-used-mib",
32548            "800",
32549            "--provider-pid",
32550            "42",
32551            "--strict",
32552            "--json",
32553        ]);
32554        match cli.command {
32555            Some(Commands::LocalModel {
32556                command:
32557                    LocalModelCommand::Unload {
32558                        profile,
32559                        provider_pid,
32560                        pre_used_mib,
32561                        post_used_mib,
32562                        strict,
32563                        json,
32564                        ..
32565                    },
32566            }) => {
32567                assert_eq!(profile, "qwen3-32b-q4");
32568                assert_eq!(provider_pid, Some(42));
32569                assert_eq!(pre_used_mib, Some(200));
32570                assert_eq!(post_used_mib, Some(800));
32571                assert!(strict);
32572                assert!(json);
32573            }
32574            _ => panic!("expected LocalModel unload command"),
32575        }
32576    }
32577
32578    #[test]
32579    fn cli_local_model_swap_parses_flags() {
32580        let cli = parse_cli([
32581            "tsift",
32582            "local-model",
32583            "swap",
32584            "--from",
32585            "qwen3-32b-q4",
32586            "--to",
32587            "qwen3-embedding-0.6b",
32588            "--provider-pid",
32589            "42",
32590            "--pre-used-mib",
32591            "200",
32592            "--post-used-mib",
32593            "180",
32594            "--strict",
32595            "--json",
32596        ]);
32597        match cli.command {
32598            Some(Commands::LocalModel {
32599                command:
32600                    LocalModelCommand::Swap {
32601                        from,
32602                        to,
32603                        provider_pid,
32604                        pre_used_mib,
32605                        post_used_mib,
32606                        strict,
32607                        json,
32608                        ..
32609                    },
32610            }) => {
32611                assert_eq!(from, "qwen3-32b-q4");
32612                assert_eq!(to, "qwen3-embedding-0.6b");
32613                assert_eq!(provider_pid, Some(42));
32614                assert_eq!(pre_used_mib, Some(200));
32615                assert_eq!(post_used_mib, Some(180));
32616                assert!(strict);
32617                assert!(json);
32618            }
32619            _ => panic!("expected LocalModel swap command"),
32620        }
32621    }
32622
32623    #[test]
32624    fn cli_local_model_resolve_parses_flags() {
32625        use cli::ResolveRole;
32626        let cli = parse_cli([
32627            "tsift",
32628            "local-model",
32629            "resolve",
32630            "--profile",
32631            "hash",
32632            "--role",
32633            "embed",
32634            "--no-probe",
32635            "--json",
32636        ]);
32637        match cli.command {
32638            Some(Commands::LocalModel {
32639                command:
32640                    LocalModelCommand::Resolve {
32641                        profile,
32642                        role,
32643                        no_probe,
32644                        json,
32645                    },
32646            }) => {
32647                assert_eq!(profile.as_deref(), Some("hash"));
32648                assert_eq!(role, ResolveRole::Embed);
32649                assert!(no_probe);
32650                assert!(json);
32651            }
32652            _ => panic!("expected LocalModel resolve command"),
32653        }
32654    }
32655
32656    #[test]
32657    fn cli_semantic_command_accepts_profile_flag() {
32658        let cli = parse_cli([
32659            "tsift",
32660            "semantic",
32661            "auth",
32662            "--profile",
32663            "qwen3-embedding-0.6b",
32664            "--json",
32665        ]);
32666        match cli.command {
32667            Some(Commands::Semantic { profile, query, .. }) => {
32668                assert_eq!(query, "auth");
32669                assert_eq!(profile.as_deref(), Some("qwen3-embedding-0.6b"));
32670            }
32671            _ => panic!("expected Semantic command"),
32672        }
32673    }
32674
32675    #[test]
32676    fn cli_summarize_command_accepts_profile_flag() {
32677        let cli = parse_cli([
32678            "tsift",
32679            "summarize",
32680            "--extract",
32681            "src",
32682            "--profile",
32683            "hash",
32684            "--json",
32685        ]);
32686        match cli.command {
32687            Some(Commands::Summarize {
32688                extract,
32689                profile,
32690                json,
32691                ..
32692            }) => {
32693                assert_eq!(extract.as_deref(), Some(std::path::Path::new("src")));
32694                assert_eq!(profile.as_deref(), Some("hash"));
32695                assert!(json);
32696            }
32697            _ => panic!("expected Summarize command"),
32698        }
32699    }
32700
32701    #[test]
32702    fn cli_local_model_lease_acquire_parses_flags() {
32703        let cli = parse_cli([
32704            "tsift",
32705            "local-model",
32706            "lease",
32707            "acquire",
32708            "--profile",
32709            "qwen3-32b-q4",
32710            "--holder-pid",
32711            "4242",
32712            "--holder-command",
32713            "corky",
32714            "--idle-ttl-seconds",
32715            "120",
32716            "--vram-baseline-mib",
32717            "200",
32718            "--lease-file",
32719            "/tmp/tsift-lease.json",
32720            "--strict",
32721            "--json",
32722        ]);
32723        match cli.command {
32724            Some(Commands::LocalModel {
32725                command:
32726                    LocalModelCommand::Lease {
32727                        command:
32728                            LeaseCommand::Acquire {
32729                                profile,
32730                                holder_pid,
32731                                holder_command,
32732                                idle_ttl_seconds,
32733                                vram_baseline_mib,
32734                                lease_file,
32735                                strict,
32736                                json,
32737                                ..
32738                            },
32739                    },
32740            }) => {
32741                assert_eq!(profile, "qwen3-32b-q4");
32742                assert_eq!(holder_pid, Some(4242));
32743                assert_eq!(holder_command, "corky");
32744                assert_eq!(idle_ttl_seconds, 120);
32745                assert_eq!(vram_baseline_mib, Some(200));
32746                assert_eq!(lease_file, Some(PathBuf::from("/tmp/tsift-lease.json")));
32747                assert!(strict);
32748                assert!(json);
32749            }
32750            _ => panic!("expected LocalModel lease acquire command"),
32751        }
32752    }
32753
32754    #[test]
32755    fn cli_local_model_lease_release_parses_flags() {
32756        let cli = parse_cli([
32757            "tsift",
32758            "local-model",
32759            "lease",
32760            "release",
32761            "--profile",
32762            "qwen3-embedding-0.6b",
32763            "--holder-pid",
32764            "999",
32765            "--json",
32766        ]);
32767        match cli.command {
32768            Some(Commands::LocalModel {
32769                command:
32770                    LocalModelCommand::Lease {
32771                        command:
32772                            LeaseCommand::Release {
32773                                profile,
32774                                holder_pid,
32775                                json,
32776                                ..
32777                            },
32778                    },
32779            }) => {
32780                assert_eq!(profile, "qwen3-embedding-0.6b");
32781                assert_eq!(holder_pid, Some(999));
32782                assert!(json);
32783            }
32784            _ => panic!("expected LocalModel lease release command"),
32785        }
32786    }
32787
32788    #[test]
32789    fn cli_local_model_lease_show_parses_flags() {
32790        let cli = parse_cli([
32791            "tsift",
32792            "local-model",
32793            "lease",
32794            "show",
32795            "--include-stale",
32796            "--json",
32797        ]);
32798        match cli.command {
32799            Some(Commands::LocalModel {
32800                command:
32801                    LocalModelCommand::Lease {
32802                        command:
32803                            LeaseCommand::Show {
32804                                include_stale,
32805                                json,
32806                                ..
32807                            },
32808                    },
32809            }) => {
32810                assert!(include_stale);
32811                assert!(json);
32812            }
32813            _ => panic!("expected LocalModel lease show command"),
32814        }
32815    }
32816
32817    #[test]
32818    fn cli_search_accepts_no_autoindex_flag() {
32819        let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
32820        match cli.command {
32821            Some(Commands::Search {
32822                autoindex,
32823                no_autoindex,
32824                ..
32825            }) => {
32826                assert!(!autoindex);
32827                assert!(no_autoindex);
32828            }
32829            _ => panic!("expected Search command"),
32830        }
32831    }
32832
32833    #[test]
32834    fn cli_search_rejects_conflicting_autoindex_flags() {
32835        let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
32836        assert!(cli.is_err());
32837    }
32838
32839    // --- relativize paths ---
32840
32841    #[test]
32842    fn cli_accepts_global_absolute_flag() {
32843        let cli = parse_cli(["tsift", "--absolute", "status"]);
32844        assert!(cli.absolute);
32845        assert!(matches!(cli.command, Some(Commands::Status { .. })));
32846    }
32847
32848    #[test]
32849    fn cli_accepts_global_tabular_flag() {
32850        let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
32851        assert!(cli.tabular);
32852        assert!(matches!(cli.command, Some(Commands::Search { .. })));
32853    }
32854
32855    #[test]
32856    fn cli_tabular_with_graph() {
32857        let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
32858        assert!(cli.tabular);
32859        assert!(matches!(cli.command, Some(Commands::Graph { .. })));
32860    }
32861
32862    #[test]
32863    fn cli_tabular_with_communities() {
32864        let cli = parse_cli(["tsift", "--tabular", "communities"]);
32865        assert!(cli.tabular);
32866        assert!(matches!(cli.command, Some(Commands::Communities { .. })));
32867    }
32868
32869    #[test]
32870    fn cli_tabular_with_explain() {
32871        let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
32872        assert!(cli.tabular);
32873        assert!(matches!(cli.command, Some(Commands::Explain { .. })));
32874    }
32875
32876    #[test]
32877    fn cli_traverse_accepts_path_target_and_html_format() {
32878        let cli = parse_cli([
32879            "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
32880        ]);
32881        match cli.command {
32882            Some(Commands::Traverse {
32883                node,
32884                to,
32885                path,
32886                format,
32887                ..
32888            }) => {
32889                assert_eq!(node.as_deref(), Some("#kgnv"));
32890                assert_eq!(to.as_deref(), Some("main"));
32891                assert_eq!(path, PathBuf::from("."));
32892                assert_eq!(format, TraverseFormat::Html);
32893            }
32894            _ => panic!("expected Traverse command"),
32895        }
32896    }
32897
32898    #[test]
32899    fn cli_parses_semantic_related_command() {
32900        let cli = parse_cli([
32901            "tsift",
32902            "semantic",
32903            "graph navigation",
32904            "--path",
32905            ".",
32906            "--kind",
32907            "all",
32908            "--limit",
32909            "3",
32910            "--json",
32911        ]);
32912        match cli.command {
32913            Some(Commands::Semantic {
32914                query,
32915                path,
32916                kind,
32917                limit,
32918                json,
32919                ..
32920            }) => {
32921                assert_eq!(query, "graph navigation");
32922                assert_eq!(path, PathBuf::from("."));
32923                assert_eq!(kind, SemanticRelatedKind::All);
32924                assert_eq!(limit, 3);
32925                assert!(json);
32926            }
32927            _ => panic!("expected Semantic command"),
32928        }
32929    }
32930
32931    #[test]
32932    fn cli_parses_convex_sync_command() {
32933        let cli = parse_cli([
32934            "tsift",
32935            "convex-sync",
32936            ".",
32937            "--snapshot",
32938            "rows.json",
32939            "--chunk-size",
32940            "25",
32941            "--json",
32942        ]);
32943        match cli.command {
32944            Some(Commands::ConvexSync {
32945                path,
32946                snapshot,
32947                chunk_size,
32948                json,
32949                ..
32950            }) => {
32951                assert_eq!(path, PathBuf::from("."));
32952                assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
32953                assert_eq!(chunk_size, 25);
32954                assert!(json);
32955            }
32956            _ => panic!("expected ConvexSync command"),
32957        }
32958    }
32959
32960    #[test]
32961    fn cli_parses_convex_sync_live_flags() {
32962        let cli = parse_cli([
32963            "tsift",
32964            "convex-sync",
32965            ".",
32966            "--remote-snapshot",
32967            "--apply",
32968            "--endpoint",
32969            "https://example.test/convex-graph",
32970            "--auth-token-env",
32971            "TSIFT_TEST_TOKEN",
32972        ]);
32973        match cli.command {
32974            Some(Commands::ConvexSync {
32975                remote_snapshot,
32976                apply,
32977                endpoint,
32978                auth_token_env,
32979                ..
32980            }) => {
32981                assert!(remote_snapshot);
32982                assert!(apply);
32983                assert_eq!(
32984                    endpoint.as_deref(),
32985                    Some("https://example.test/convex-graph")
32986                );
32987                assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
32988            }
32989            _ => panic!("expected ConvexSync command"),
32990        }
32991    }
32992
32993    #[test]
32994    fn cli_parses_graph_db_query() {
32995        let cli = parse_cli([
32996            "tsift",
32997            "graph-db",
32998            "--backend",
32999            "convex-snapshot",
33000            "--convex-snapshot",
33001            "rows.json",
33002            "--json",
33003            "neighborhood",
33004            "gbak-kgnv",
33005            "--depth",
33006            "2",
33007            "--edge-kind",
33008            "mentions",
33009            "--property",
33010            "path=tasks/software/tsift.md",
33011            "--cursor",
33012            "gbak-old",
33013            "--limit",
33014            "10",
33015        ]);
33016        match cli.command {
33017            Some(Commands::GraphDb {
33018                backend,
33019                convex_snapshot,
33020                json,
33021                query,
33022                ..
33023            }) => {
33024                assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
33025                assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
33026                assert!(json);
33027                match query {
33028                    GraphDbQuery::Neighborhood {
33029                        id,
33030                        depth,
33031                        edge_kind,
33032                        cursor,
33033                        limit,
33034                        property_filters,
33035                    } => {
33036                        assert_eq!(id, "gbak-kgnv");
33037                        assert_eq!(depth, 2);
33038                        assert_eq!(edge_kind.as_deref(), Some("mentions"));
33039                        assert_eq!(cursor.as_deref(), Some("gbak-old"));
33040                        assert_eq!(limit, Some(10));
33041                        assert_eq!(
33042                            property_filters,
33043                            vec!["path=tasks/software/tsift.md".to_string()]
33044                        );
33045                    }
33046                    _ => panic!("expected graph-db neighborhood query"),
33047                }
33048            }
33049            _ => panic!("expected GraphDb command"),
33050        }
33051    }
33052
33053    #[test]
33054    fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
33055        let cli = parse_cli([
33056            "tsift",
33057            "graph-db",
33058            "--json",
33059            "backend-eval",
33060            "--candidate",
33061            "surrealdb",
33062            "--target",
33063            "gval",
33064            "--full-projection",
33065        ]);
33066        match cli.command {
33067            Some(Commands::GraphDb { json, query, .. }) => {
33068                assert!(json);
33069                match query {
33070                    GraphDbQuery::BackendEval {
33071                        candidates,
33072                        targets,
33073                        full_projection,
33074                    } => {
33075                        assert_eq!(candidates, vec!["surrealdb".to_string()]);
33076                        assert_eq!(targets, vec!["gval".to_string()]);
33077                        assert!(full_projection);
33078                    }
33079                    _ => panic!("expected graph-db backend-eval query"),
33080                }
33081            }
33082            _ => panic!("expected GraphDb command"),
33083        }
33084    }
33085
33086    #[test]
33087    fn cli_parses_graph_db_tokensave_backend() {
33088        let cli = parse_cli([
33089            "tsift",
33090            "graph-db",
33091            "--backend",
33092            "tokensave",
33093            "--json",
33094            "node",
33095            "fn:main",
33096        ]);
33097        match cli.command {
33098            Some(Commands::GraphDb {
33099                backend,
33100                json,
33101                query,
33102                ..
33103            }) => {
33104                assert_eq!(backend, GraphDbBackend::Tokensave);
33105                assert!(json);
33106                match query {
33107                    GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
33108                    _ => panic!("expected graph-db node query"),
33109                }
33110            }
33111            _ => panic!("expected GraphDb command"),
33112        }
33113    }
33114
33115    #[test]
33116    fn cli_parses_analyze_command() {
33117        let cli = parse_cli([
33118            "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
33119            "--limit", "7", "--json",
33120        ]);
33121        match cli.command {
33122            Some(Commands::Analyze {
33123                path,
33124                scope,
33125                entry_points,
33126                limit,
33127                json,
33128            }) => {
33129                assert_eq!(path, PathBuf::from("."));
33130                assert_eq!(scope.as_deref(), Some("core"));
33131                assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
33132                assert_eq!(limit, 7);
33133                assert!(json);
33134            }
33135            _ => panic!("expected Analyze command"),
33136        }
33137    }
33138
33139    #[test]
33140    fn cli_parses_graph_db_related_query() {
33141        let cli = parse_cli([
33142            "tsift",
33143            "graph-db",
33144            "--json",
33145            "related",
33146            "voice avatar memory retrieval",
33147            "--kind",
33148            "all",
33149            "--depth",
33150            "3",
33151            "--seed-limit",
33152            "4",
33153            "--limit",
33154            "12",
33155        ]);
33156        match cli.command {
33157            Some(Commands::GraphDb { json, query, .. }) => {
33158                assert!(json);
33159                match query {
33160                    GraphDbQuery::Related {
33161                        query,
33162                        kind,
33163                        depth,
33164                        seed_limit,
33165                        limit,
33166                    } => {
33167                        assert_eq!(query, "voice avatar memory retrieval");
33168                        assert_eq!(kind, SemanticRelatedKind::All);
33169                        assert_eq!(depth, 3);
33170                        assert_eq!(seed_limit, 4);
33171                        assert_eq!(limit, 12);
33172                    }
33173                    _ => panic!("expected graph-db related query"),
33174                }
33175            }
33176            _ => panic!("expected GraphDb command"),
33177        }
33178    }
33179
33180    #[test]
33181    fn cli_parses_graph_db_compact_query() {
33182        let cli = parse_cli([
33183            "tsift",
33184            "graph-db",
33185            "--path",
33186            ".",
33187            "compact",
33188            "--apply",
33189            "--prune-tombstones",
33190            "--confirmed-convex-reconciled",
33191        ]);
33192        match cli.command {
33193            Some(Commands::GraphDb { query, .. }) => match query {
33194                GraphDbQuery::Compact {
33195                    apply,
33196                    prune_tombstones,
33197                    confirmed_convex_reconciled,
33198                } => {
33199                    assert!(apply);
33200                    assert!(prune_tombstones);
33201                    assert!(confirmed_convex_reconciled);
33202                }
33203                _ => panic!("expected graph-db compact query"),
33204            },
33205            _ => panic!("expected GraphDb command"),
33206        }
33207    }
33208
33209    #[test]
33210    fn cli_parses_graph_db_snapshot_queries() {
33211        let export_cli = parse_cli([
33212            "tsift",
33213            "graph-db",
33214            "--json",
33215            "snapshot-export",
33216            "graph.db.gz",
33217            "--force",
33218        ]);
33219        match export_cli.command {
33220            Some(Commands::GraphDb { json, query, .. }) => {
33221                assert!(json);
33222                match query {
33223                    GraphDbQuery::SnapshotExport { output, force } => {
33224                        assert_eq!(output, PathBuf::from("graph.db.gz"));
33225                        assert!(force);
33226                    }
33227                    _ => panic!("expected graph-db snapshot-export query"),
33228                }
33229            }
33230            _ => panic!("expected GraphDb command"),
33231        }
33232
33233        let import_cli = parse_cli([
33234            "tsift",
33235            "graph-db",
33236            "snapshot-import",
33237            "graph.db.gz",
33238            "--replace",
33239        ]);
33240        match import_cli.command {
33241            Some(Commands::GraphDb { query, .. }) => match query {
33242                GraphDbQuery::SnapshotImport { artifact, replace } => {
33243                    assert_eq!(artifact, PathBuf::from("graph.db.gz"));
33244                    assert!(replace);
33245                }
33246                _ => panic!("expected graph-db snapshot-import query"),
33247            },
33248            _ => panic!("expected GraphDb command"),
33249        }
33250    }
33251
33252    #[test]
33253    fn cli_parses_impact_command() {
33254        let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
33255        match cli.command {
33256            Some(Commands::Impact {
33257                path,
33258                cached,
33259                limit,
33260                ..
33261            }) => {
33262                assert_eq!(path, PathBuf::from("."));
33263                assert!(cached);
33264                assert_eq!(limit, 5);
33265            }
33266            _ => panic!("expected Impact command"),
33267        }
33268    }
33269
33270    #[test]
33271    fn cli_parses_conflict_matrix_command() {
33272        let cli = parse_cli([
33273            "tsift",
33274            "conflict-matrix",
33275            "--path",
33276            "tasks/software/tsift.md",
33277            "--depth",
33278            "4",
33279            "--limit",
33280            "12",
33281            "--impact-limit",
33282            "6",
33283            "--json",
33284            "pwcm",
33285            "#g6kf",
33286        ]);
33287        match cli.command {
33288            Some(Commands::ConflictMatrix {
33289                targets,
33290                path,
33291                depth,
33292                limit,
33293                impact_limit,
33294                json,
33295                ..
33296            }) => {
33297                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33298                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33299                assert_eq!(depth, 4);
33300                assert_eq!(limit, 12);
33301                assert_eq!(impact_limit, 6);
33302                assert!(json);
33303            }
33304            _ => panic!("expected ConflictMatrix command"),
33305        }
33306    }
33307
33308    #[test]
33309    fn cli_parses_dispatch_trace_command() {
33310        let cli = parse_cli([
33311            "tsift",
33312            "dispatch-trace",
33313            "--path",
33314            "tasks/software/tsift.md",
33315            "--format",
33316            "html",
33317            "--depth",
33318            "4",
33319            "pwcm",
33320            "#g6kf",
33321        ]);
33322        match cli.command {
33323            Some(Commands::DispatchTrace {
33324                targets,
33325                path,
33326                format,
33327                depth,
33328                ..
33329            }) => {
33330                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33331                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33332                assert_eq!(format, DispatchTraceFormat::Html);
33333                assert_eq!(depth, 4);
33334            }
33335            _ => panic!("expected DispatchTrace command"),
33336        }
33337    }
33338
33339    #[test]
33340    fn cli_parses_dependency_dag_command() {
33341        let cli = parse_cli([
33342            "tsift",
33343            "dependency-dag",
33344            "--path",
33345            "tasks/software/tsift.md",
33346            "--depth",
33347            "5",
33348            "--limit",
33349            "20",
33350            "--json",
33351            "alpha",
33352            "#beta",
33353        ]);
33354        match cli.command {
33355            Some(Commands::DependencyDag {
33356                targets,
33357                path,
33358                depth,
33359                limit,
33360                json,
33361                ..
33362            }) => {
33363                assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
33364                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33365                assert_eq!(depth, 5);
33366                assert_eq!(limit, 20);
33367                assert!(json);
33368            }
33369            _ => panic!("expected DependencyDag command"),
33370        }
33371    }
33372
33373    #[test]
33374    fn relativize_strips_root_prefix() {
33375        let root = std::path::Path::new("/home/user/project");
33376        assert_eq!(
33377            relativize("/home/user/project/src/main.rs", root),
33378            "src/main.rs"
33379        );
33380    }
33381
33382    #[test]
33383    fn relativize_leaves_non_matching_path() {
33384        let root = std::path::Path::new("/home/user/project");
33385        assert_eq!(
33386            relativize("/other/path/file.rs", root),
33387            "/other/path/file.rs"
33388        );
33389    }
33390
33391    #[test]
33392    fn relativize_leaves_already_relative() {
33393        let root = std::path::Path::new("/home/user/project");
33394        assert_eq!(relativize("src/main.rs", root), "src/main.rs");
33395    }
33396
33397    #[test]
33398    fn relativize_pathbuf_strips_prefix() {
33399        let root = std::path::Path::new("/home/user/project");
33400        let path = std::path::Path::new("/home/user/project/src/lib.rs");
33401        assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
33402    }
33403
33404    #[test]
33405    fn relativize_edges_strips_caller_file() {
33406        let root = std::path::Path::new("/tmp/proj");
33407        let mut edges = vec![index::StoredEdge {
33408            caller_file: "/tmp/proj/src/main.rs".to_string(),
33409            caller_name: "main".to_string(),
33410            caller_line: 1,
33411            callee_name: "helper".to_string(),
33412            call_site_line: 5,
33413            tagpath_handle: None,
33414        }];
33415        relativize_edges(&mut edges, root);
33416        assert_eq!(edges[0].caller_file, "src/main.rs");
33417    }
33418
33419    #[test]
33420    fn relativize_json_paths_strips_known_keys() {
33421        let root = std::path::Path::new("/tmp/proj");
33422        let mut val = serde_json::json!({
33423            "file": "/tmp/proj/src/main.rs",
33424            "path": "/tmp/proj/test.rs",
33425            "name": "/tmp/proj/not-a-path",
33426            "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
33427        });
33428        relativize_json_paths(&mut val, root);
33429        assert_eq!(val["file"], "src/main.rs");
33430        assert_eq!(val["path"], "test.rs");
33431        assert_eq!(val["name"], "/tmp/proj/not-a-path");
33432        assert_eq!(val["hits"][0]["path"], "nested.rs");
33433    }
33434
33435    // --- limit caps ---
33436
33437    #[test]
33438    fn cli_graph_accepts_limit_flag() {
33439        let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
33440        match cli.command {
33441            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
33442            _ => panic!("expected Graph command"),
33443        }
33444    }
33445
33446    #[test]
33447    fn cli_graph_default_limit_is_20() {
33448        let cli = parse_cli(["tsift", "graph", "main"]);
33449        match cli.command {
33450            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
33451            _ => panic!("expected Graph command"),
33452        }
33453    }
33454
33455    #[test]
33456    fn cli_communities_accepts_limit_flag() {
33457        let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
33458        match cli.command {
33459            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
33460            _ => panic!("expected Communities command"),
33461        }
33462    }
33463
33464    #[test]
33465    fn cli_communities_default_limit_is_10() {
33466        let cli = parse_cli(["tsift", "communities"]);
33467        match cli.command {
33468            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
33469            _ => panic!("expected Communities command"),
33470        }
33471    }
33472
33473    #[test]
33474    fn cli_explain_accepts_limit_flag() {
33475        let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
33476        match cli.command {
33477            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
33478            _ => panic!("expected Explain command"),
33479        }
33480    }
33481
33482    #[test]
33483    fn cli_explain_default_limit_is_15() {
33484        let cli = parse_cli(["tsift", "explain", "main"]);
33485        match cli.command {
33486            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
33487            _ => panic!("expected Explain command"),
33488        }
33489    }
33490
33491    #[test]
33492    fn cli_limit_zero_means_unlimited() {
33493        let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
33494        match cli.command {
33495            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
33496            _ => panic!("expected Graph command"),
33497        }
33498    }
33499
33500    #[test]
33501    fn graph_cmd_limit_runs_ok() {
33502        let dir = setup_graph_index();
33503        let result = cmd_graph(
33504            "main",
33505            dir.path(),
33506            false,
33507            false,
33508            None,
33509            1,
33510            false,
33511            false,
33512            false,
33513            false,
33514            false,
33515            false,
33516            false,
33517            TagpathSearchOpts::default(),
33518        );
33519        assert!(result.is_ok());
33520    }
33521
33522    #[test]
33523    fn graph_cmd_unlimited_runs_ok() {
33524        let dir = setup_graph_index();
33525        let result = cmd_graph(
33526            "main",
33527            dir.path(),
33528            false,
33529            false,
33530            None,
33531            0,
33532            false,
33533            false,
33534            false,
33535            false,
33536            false,
33537            false,
33538            false,
33539            TagpathSearchOpts::default(),
33540        );
33541        assert!(result.is_ok());
33542    }
33543
33544    #[test]
33545    fn graph_cmd_tabular_runs_ok() {
33546        let dir = setup_graph_index();
33547        let result = cmd_graph(
33548            "main",
33549            dir.path(),
33550            false,
33551            false,
33552            None,
33553            20,
33554            false,
33555            false,
33556            false,
33557            false,
33558            false,
33559            true,
33560            false,
33561            TagpathSearchOpts::default(),
33562        );
33563        assert!(result.is_ok());
33564    }
33565
33566    #[test]
33567    fn communities_cmd_tabular_runs_ok() {
33568        let dir = setup_graph_index();
33569        let result = cmd_communities(
33570            dir.path(),
33571            None,
33572            1,
33573            10,
33574            false,
33575            false,
33576            false,
33577            false,
33578            true,
33579            false,
33580            TagpathSearchOpts::default(),
33581        );
33582        assert!(result.is_ok());
33583    }
33584
33585    #[test]
33586    fn explain_cmd_tabular_runs_ok() {
33587        let dir = setup_graph_index();
33588        let result = cmd_explain(
33589            "main",
33590            dir.path(),
33591            None,
33592            15,
33593            false,
33594            false,
33595            false,
33596            false,
33597            false,
33598            true,
33599            false,
33600            false,
33601        );
33602        assert!(result.is_ok());
33603    }
33604
33605    #[test]
33606    fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
33607        // #gdbcacheprove: .agent-doc runtime markdown (snapshots, baselines, archives,
33608        // session docs, runtime logs) must not contribute to the source watermark, or
33609        // every agent-doc cycle would invalidate the graph-db backend-eval cache and
33610        // force a full rebuild on the next run.
33611        let cases = [
33612            ".agent-doc",
33613            ".agent-doc/snapshots/abc.md",
33614            ".agent-doc/baselines/abc.md",
33615            ".agent-doc/archives/2026.md",
33616            ".agent-doc/runtime/run.jsonl",
33617            "src/foo/.agent-doc",
33618            "src/foo/.agent-doc/snapshots/x.md",
33619            "./.agent-doc/snapshots/x.md",
33620        ];
33621        for path in cases {
33622            assert!(
33623                traversal_relative_path_is_generated_artifact(path),
33624                "expected `{path}` to be excluded from source watermark"
33625            );
33626        }
33627        // Real source paths must NOT be excluded.
33628        for path in [
33629            "src/main.rs",
33630            "tests/perf_gate.rs",
33631            "fixtures/x.json",
33632            "agent-doc/src/lib.rs", // sibling dir without the leading dot
33633            "src/.agent-doc-helper.rs",
33634        ] {
33635            assert!(
33636                !traversal_relative_path_is_generated_artifact(path),
33637                "expected `{path}` to be included in source watermark"
33638            );
33639        }
33640    }
33641
33642    #[test]
33643    fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
33644        // #cachelookupshift: the conflict-matrix preparation cache key hashes
33645        // file_state snapshot rows + every markdown file under the root. Any
33646        // .tsift/, target/, or .agent-doc/ path slipping past the filter would
33647        // shift the watermark every run because those directories mutate as a
33648        // side effect of running tsift itself. This test locks the artifact
33649        // filter against regressions for each prefix variant
33650        // (bare, root-anchored, nested, and './' leading).
33651        let cases = [
33652            ".tsift",
33653            ".tsift/index.db",
33654            ".tsift/indexes/foo/index.db",
33655            ".tsift/conflict-matrix-cache/inputs/abc.json",
33656            ".tsift/summaries.db",
33657            "src/foo/.tsift",
33658            "src/foo/.tsift/graph.db",
33659            "./.tsift/index.db",
33660            "target",
33661            "target/debug/build/x",
33662            "target/release/tsift",
33663            "src/foo/target/debug/x",
33664            "./target/release/x",
33665        ];
33666        for path in cases {
33667            assert!(
33668                traversal_relative_path_is_generated_artifact(path),
33669                "expected `{path}` to be excluded from source watermark"
33670            );
33671        }
33672        // Look-alike paths must NOT be excluded — only true artifact dirs.
33673        for path in [
33674            "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
33675            "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
33676            "src/tsift-extras/lib.rs",
33677            "tsift/README.md",
33678            "src/targeting.rs",
33679            "src/.tsiftrc",
33680            "src/agent-doc-helper.rs",
33681        ] {
33682            assert!(
33683                !traversal_relative_path_is_generated_artifact(path),
33684                "expected `{path}` to be included in source watermark"
33685            );
33686        }
33687    }
33688
33689    #[test]
33690    fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
33691        // #cachelookupshift: the conflict-matrix preparation cache only hits
33692        // when traversal_source_watermark returns the same hash for two
33693        // consecutive calls on identical source state. Lock that invariant so
33694        // a future change that folds wall-clock time, a directory mtime, or
33695        // any other non-content input into the hash trips this test before
33696        // regressing the preparation_cache_lookup hit rate. We exercise the
33697        // session_only=true path with a hinted markdown file so the test does
33698        // not need a full index DB to drive the index-snapshot branch.
33699        let dir = tempfile::tempdir().unwrap();
33700        let root = dir.path();
33701        std::fs::create_dir_all(root.join("src")).unwrap();
33702        std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
33703        let hint = root.join("README.md");
33704        std::fs::write(&hint, "# stable\n").unwrap();
33705        // Add a generated-artifact directory that must NOT affect the watermark.
33706        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33707        std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
33708        std::fs::create_dir_all(root.join("target/debug")).unwrap();
33709        std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
33710
33711        let first = traversal_source_watermark(root, &hint, None, true)
33712            .expect("first watermark call must succeed")
33713            .expect("first watermark must produce a hash for hinted markdown");
33714        let second = traversal_source_watermark(root, &hint, None, true)
33715            .expect("second watermark call must succeed")
33716            .expect("second watermark must produce a hash for hinted markdown");
33717        assert_eq!(
33718            first, second,
33719            "watermark must be identical across back-to-back invocations on a quiescent root"
33720        );
33721
33722        // Mutating a generated-artifact file must NOT shift the hash.
33723        std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
33724        std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
33725        let third = traversal_source_watermark(root, &hint, None, true)
33726            .expect("third watermark call must succeed")
33727            .expect("third watermark must produce a hash for hinted markdown");
33728        assert_eq!(
33729            first, third,
33730            "watermark must ignore mutations under .tsift/ and target/"
33731        );
33732
33733        // Mutating the hinted markdown file MUST shift the hash so the
33734        // preparation cache invalidates correctly when user state changes.
33735        // Sleep briefly to push the file mtime past the original even on
33736        // coarse-resolution filesystems.
33737        std::thread::sleep(std::time::Duration::from_millis(20));
33738        std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
33739        let fourth = traversal_source_watermark(root, &hint, None, true)
33740            .expect("fourth watermark call must succeed")
33741            .expect("fourth watermark must produce a hash for hinted markdown");
33742        assert_ne!(
33743            first, fourth,
33744            "watermark must invalidate when the hinted markdown file changes"
33745        );
33746    }
33747
33748    #[test]
33749    fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
33750        // #gcachemiss: full-projection cache keys must not miss just because the
33751        // SQLite summary cache file header or mtime churned. Only the semantic rows
33752        // that feed traversal projection should participate in the source watermark.
33753        let dir = tempfile::tempdir().unwrap();
33754        let root = dir.path();
33755        std::fs::write(root.join("README.md"), "# stable\n").unwrap();
33756        let summaries_db_path = root.join(".tsift/summaries.db");
33757        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33758        let mut summary = summarize::Summary {
33759            id: 0,
33760            symbol_name: "main".to_string(),
33761            file_path: "src/main.rs".to_string(),
33762            content_hash: "hash-main".to_string(),
33763            summary: "main wires the CLI".to_string(),
33764            entities: Some(vec![summarize::Entity {
33765                name: "Cli".to_string(),
33766                kind: "type".to_string(),
33767                description: "Command-line interface".to_string(),
33768            }]),
33769            relationships: None,
33770            concept_labels: Some(vec!["cli".to_string()]),
33771            extracted_at: "1700000000".to_string(),
33772            model: "test-model".to_string(),
33773            tokens_input: Some(10),
33774            tokens_output: Some(5),
33775        };
33776        summary_db.insert(&summary).unwrap();
33777        drop(summary_db);
33778
33779        let hint = root.join("README.md");
33780        let first = traversal_source_watermark(root, &hint, None, true)
33781            .expect("first watermark call must succeed")
33782            .expect("first watermark must produce a hash");
33783
33784        std::thread::sleep(std::time::Duration::from_millis(20));
33785        let conn = Connection::open(&summaries_db_path).unwrap();
33786        conn.pragma_update(None, "user_version", 1).unwrap();
33787        conn.pragma_update(None, "user_version", 0).unwrap();
33788        drop(conn);
33789
33790        let second = traversal_source_watermark(root, &hint, None, true)
33791            .expect("second watermark call must succeed")
33792            .expect("second watermark must produce a hash");
33793        assert_eq!(
33794            first, second,
33795            "metadata-only summaries.db churn must not invalidate the source watermark"
33796        );
33797
33798        summary.entities = Some(vec![summarize::Entity {
33799            name: "GraphCache".to_string(),
33800            kind: "type".to_string(),
33801            description: "Stable full-projection cache input".to_string(),
33802        }]);
33803        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33804        summary_db.delete_by_file("src/main.rs").unwrap();
33805        summary_db.insert(&summary).unwrap();
33806        drop(summary_db);
33807
33808        let third = traversal_source_watermark(root, &hint, None, true)
33809            .expect("third watermark call must succeed")
33810            .expect("third watermark must produce a hash");
33811        assert_ne!(
33812            first, third,
33813            "semantic summary row changes must invalidate the source watermark"
33814        );
33815    }
33816
33817    #[test]
33818    fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
33819        // #gfullhot: backend-eval full-projection cache keys should be based on
33820        // the indexed graph inputs, not file_state mtimes. Touching a source file
33821        // without changing extracted symbols/call edges must still hit the cache.
33822        let dir = tempfile::tempdir().unwrap();
33823        let root = dir.path();
33824        std::fs::create_dir_all(root.join("src")).unwrap();
33825        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33826        let source = root.join("src/lib.rs");
33827        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33828        std::fs::write(&source, source_body).unwrap();
33829        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33830        db.rebuild(root).unwrap();
33831        drop(db);
33832
33833        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33834            .unwrap()
33835            .value;
33836        std::thread::sleep(std::time::Duration::from_millis(20));
33837        std::fs::write(&source, source_body).unwrap();
33838        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33839        db.apply_changes(root).unwrap();
33840        drop(db);
33841
33842        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33843            .unwrap()
33844            .value;
33845        assert_eq!(
33846            first, second,
33847            "mtime-only source index churn must not invalidate the full-projection cache"
33848        );
33849    }
33850
33851    #[test]
33852    fn full_projection_source_watermark_ignores_session_markdown_churn() {
33853        // #gfullhot: the full-projection performance cache isolates code graph
33854        // and semantic-summary inputs. Current session evidence is measured by
33855        // the bounded real dataset, so unrelated task-doc edits must not force a
33856        // million-row full-projection rebuild.
33857        let dir = tempfile::tempdir().unwrap();
33858        let root = dir.path();
33859        std::fs::create_dir_all(root.join("src")).unwrap();
33860        std::fs::create_dir_all(root.join("tasks/software")).unwrap();
33861        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33862        std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
33863        let task_doc = root.join("tasks/software/tsift.md");
33864        std::fs::write(
33865            &task_doc,
33866            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
33867        )
33868        .unwrap();
33869        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33870        db.rebuild(root).unwrap();
33871        drop(db);
33872
33873        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33874            .unwrap()
33875            .value;
33876        std::fs::write(
33877            &task_doc,
33878            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
33879        )
33880        .unwrap();
33881        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33882            .unwrap()
33883            .value;
33884        assert_eq!(
33885            first, second,
33886            "session markdown churn must not invalidate the full-projection code/summary cache"
33887        );
33888    }
33889
33890    #[test]
33891    fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
33892        // #gfullhot: once a full-project projection is cached, repeated samples
33893        // with unchanged graph inputs must report zero source_graph_build and
33894        // projection_rows work even if indexed file mtimes changed.
33895        let dir = tempfile::tempdir().unwrap();
33896        let root = dir.path();
33897        std::fs::create_dir_all(root.join("src")).unwrap();
33898        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33899        let source = root.join("src/lib.rs");
33900        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33901        std::fs::write(&source, source_body).unwrap();
33902        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33903        db.rebuild(root).unwrap();
33904        drop(db);
33905
33906        let (_projection, _warnings, _phases, first_stats) =
33907            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33908        assert!(
33909            !first_stats.hit,
33910            "the first full-projection run should populate the cache"
33911        );
33912
33913        std::thread::sleep(std::time::Duration::from_millis(20));
33914        std::fs::write(&source, source_body).unwrap();
33915        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33916        db.apply_changes(root).unwrap();
33917        drop(db);
33918
33919        let (_projection, _warnings, phases, second_stats) =
33920            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33921        assert!(second_stats.hit, "mtime-only churn should still cache-hit");
33922        let source_graph_build = phases
33923            .iter()
33924            .find(|phase| phase.name == "full_projection.source_graph_build")
33925            .expect("cache hit must report source_graph_build");
33926        let projection_rows = phases
33927            .iter()
33928            .find(|phase| phase.name == "full_projection.projection_rows")
33929            .expect("cache hit must report projection_rows");
33930        assert_eq!(source_graph_build.duration_micros, 0);
33931        assert_eq!(projection_rows.duration_micros, 0);
33932    }
33933
33934    #[test]
33935    fn build_token_capped_preview_within_cap() {
33936        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 2", "}"];
33937        let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
33938        assert!(!capped.was_capped);
33939        assert_eq!(capped.preview.len(), 3);
33940        assert_eq!(capped.capped_end, 3);
33941    }
33942
33943    #[test]
33944    fn build_token_capped_preview_truncates_long_body() {
33945        let owned: Vec<String> = (0..200)
33946            .map(|i| format!("    let line_{i} = {i};"))
33947            .collect();
33948        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33949        let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
33950        assert!(capped.was_capped);
33951        assert!(capped.preview.len() < 200);
33952        assert!(capped.capped_end < 200);
33953        assert!(!capped.preview.is_empty());
33954    }
33955
33956    #[test]
33957    fn build_token_capped_preview_respects_start_offset() {
33958        let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
33959        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33960        let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
33961        assert!(capped.was_capped);
33962        assert!(capped.capped_end >= 50);
33963        assert!(capped.capped_end < 100);
33964        assert_eq!(capped.preview[0].line, 50);
33965    }
33966
33967    #[test]
33968    fn response_budget_body_token_cap_defaults() {
33969        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
33970        assert_eq!(budget.body_token_cap(), 1500);
33971
33972        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
33973        assert_eq!(budget.body_token_cap(), 500);
33974
33975        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
33976        assert_eq!(budget.body_token_cap(), 3000);
33977    }
33978
33979    #[test]
33980    fn build_token_capped_preview_empty_input() {
33981        let lines: Vec<&str> = vec![];
33982        let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
33983        assert!(!capped.was_capped);
33984        assert!(capped.preview.is_empty());
33985    }
33986
33987    #[test]
33988    fn build_token_capped_preview_single_long_line_fits() {
33989        let lines: Vec<&str> = vec!["short"];
33990        let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
33991        assert!(!capped.was_capped);
33992        assert_eq!(capped.preview.len(), 1);
33993        assert_eq!(capped.capped_end, 1);
33994    }
33995
33996    #[test]
33997    fn edge_index_replaces_from_id_to_id_with_positions() {
33998        let input = serde_json::json!({
33999            "nodes": [
34000                {"id": "symbol:src/lib.rs:foo"},
34001                {"id": "symbol:src/lib.rs:bar"},
34002                {"id": "symbol:src/lib.rs:baz"}
34003            ],
34004            "edges": [
34005                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
34006                {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
34007            ]
34008        });
34009        let result = edge_index_transform(input);
34010        let edges = result.get("edges").unwrap().as_array().unwrap();
34011        assert_eq!(edges.len(), 2);
34012        assert_eq!(edges[0]["from"], 0);
34013        assert_eq!(edges[0]["to"], 1);
34014        assert_eq!(edges[1]["from"], 1);
34015        assert_eq!(edges[1]["to"], 2);
34016        assert!(edges[0].get("from_id").is_none());
34017        assert!(edges[0].get("to_id").is_none());
34018    }
34019
34020    #[test]
34021    fn edge_index_preserves_unresolved_ids_as_strings() {
34022        let input = serde_json::json!({
34023            "nodes": [{"id": "symbol:src/lib.rs:foo"}],
34024            "edges": [
34025                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
34026            ]
34027        });
34028        let result = edge_index_transform(input);
34029        let edge = &result["edges"][0];
34030        assert_eq!(edge["from"], 0);
34031        assert_eq!(edge["to_id"], "symbol:other.rs:missing");
34032    }
34033
34034    #[test]
34035    fn edge_index_noop_without_nodes_and_edges() {
34036        let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
34037        let result = edge_index_transform(input);
34038        assert_eq!(result["report"]["entries"][0]["from_id"], "a");
34039    }
34040}
34041
34042// --- SQL introspection ---
34043
34044#[derive(Serialize)]
34045struct TableInfo {
34046    name: String,
34047    columns: Vec<ColumnInfo>,
34048    row_count: i64,
34049}
34050
34051#[derive(Serialize)]
34052struct ColumnInfo {
34053    name: String,
34054    #[serde(rename = "type")]
34055    col_type: String,
34056    notnull: bool,
34057    pk: bool,
34058    #[serde(skip_serializing_if = "Option::is_none")]
34059    default_value: Option<String>,
34060}
34061
34062/// Open a SQLite connection (read-only).
34063pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
34064    let conn = Connection::open_with_flags(
34065        path,
34066        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
34067    )
34068    .with_context(|| format!("opening database: {}", path.display()))?;
34069    Ok(conn)
34070}
34071
34072/// List all user tables with column metadata and row counts.
34073pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
34074    let mut stmt = conn.prepare(
34075        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
34076    )?;
34077    let table_names: Vec<String> = stmt
34078        .query_map([], |row| row.get(0))?
34079        .collect::<std::result::Result<Vec<_>, _>>()?;
34080
34081    let mut tables = Vec::new();
34082    for tbl in table_names {
34083        let columns = table_columns(conn, &tbl)?;
34084        let row_count: i64 =
34085            conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
34086                row.get(0)
34087            })?;
34088        tables.push(TableInfo {
34089            name: tbl,
34090            columns,
34091            row_count,
34092        });
34093    }
34094    Ok(tables)
34095}
34096
34097/// Get column metadata for a single table.
34098pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
34099    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
34100    let cols = stmt
34101        .query_map([], |row| {
34102            Ok(ColumnInfo {
34103                name: row.get(1)?,
34104                col_type: row.get::<_, String>(2).unwrap_or_default(),
34105                notnull: row.get::<_, bool>(3).unwrap_or(false),
34106                pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
34107                default_value: row.get(4)?,
34108            })
34109        })?
34110        .collect::<std::result::Result<Vec<_>, _>>()?;
34111    Ok(cols)
34112}
34113
34114/// Execute an arbitrary SQL query and return rows as JSON values.
34115pub(crate) fn execute_query(
34116    conn: &Connection,
34117    sql: &str,
34118) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
34119    let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
34120    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
34121    let col_count = col_names.len();
34122
34123    let mut rows = Vec::new();
34124    let mut query_rows = stmt.query([])?;
34125    while let Some(row) = query_rows.next()? {
34126        let mut vals = Vec::with_capacity(col_count);
34127        for i in 0..col_count {
34128            let val = match row.get_ref(i)? {
34129                rusqlite::types::ValueRef::Null => serde_json::Value::Null,
34130                rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
34131                rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
34132                rusqlite::types::ValueRef::Text(s) => {
34133                    serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
34134                }
34135                rusqlite::types::ValueRef::Blob(b) => {
34136                    serde_json::Value::String(format!("<blob {} bytes>", b.len()))
34137                }
34138            };
34139            vals.push(val);
34140        }
34141        rows.push(vals);
34142    }
34143    Ok((col_names, rows))
34144}
34145
34146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34147enum DigestRunnerKind {
34148    Test,
34149    Log,
34150}
34151
34152impl DigestRunnerKind {
34153    fn parse(raw: &str) -> Result<Self> {
34154        match raw.trim().to_ascii_lowercase().as_str() {
34155            "test" => Ok(Self::Test),
34156            "log" => Ok(Self::Log),
34157            other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
34158        }
34159    }
34160
34161    fn as_str(self) -> &'static str {
34162        match self {
34163            Self::Test => "test",
34164            Self::Log => "log",
34165        }
34166    }
34167}
34168
34169/// Simple shell word splitting (handles single and double quotes).
34170pub(crate) fn shell_split(s: &str) -> Vec<&str> {
34171    let mut parts = Vec::new();
34172    let mut i = 0;
34173    let bytes = s.as_bytes();
34174    while i < bytes.len() {
34175        // Skip whitespace
34176        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
34177            i += 1;
34178        }
34179        if i >= bytes.len() {
34180            break;
34181        }
34182        let start = i;
34183        if bytes[i] == b'"' || bytes[i] == b'\'' {
34184            let quote = bytes[i];
34185            i += 1;
34186            while i < bytes.len() && bytes[i] != quote {
34187                i += 1;
34188            }
34189            if i < bytes.len() {
34190                i += 1; // closing quote
34191            }
34192        } else {
34193            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
34194                i += 1;
34195            }
34196        }
34197        parts.push(&s[start..i]);
34198    }
34199    parts
34200}
34201
34202/// Quote a string for shell if it contains special characters.
34203pub(crate) fn shell_quote(s: &str) -> String {
34204    // Strip existing quotes
34205    let unquoted =
34206        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
34207            &s[1..s.len() - 1]
34208        } else {
34209            s
34210        };
34211
34212    if unquoted
34213        .chars()
34214        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
34215    {
34216        format!("\"{}\"", unquoted)
34217    } else {
34218        format!(
34219            "\"{}\"",
34220            unquoted.replace('\\', "\\\\").replace('"', "\\\"")
34221        )
34222    }
34223}
34224
34225fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
34226    sift::SearchCoverageSnapshot {
34227        mode: sift::SearchCoverageMode::Sealed,
34228        total_sector_count: 0,
34229        mounted_sector_count: 0,
34230        reused_sector_count: 0,
34231        dirty_sector_count: 0,
34232        completed_dirty_sector_count: 0,
34233        rebuilding_sector_count: 0,
34234        resumed_sector_count: 0,
34235        active_rebuild: None,
34236    }
34237}
34238
34239fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
34240    let total_sector_count = responses
34241        .iter()
34242        .map(|response| response.coverage.total_sector_count)
34243        .sum();
34244    let mounted_sector_count = responses
34245        .iter()
34246        .map(|response| response.coverage.mounted_sector_count)
34247        .sum();
34248    let reused_sector_count = responses
34249        .iter()
34250        .map(|response| response.coverage.reused_sector_count)
34251        .sum();
34252    let dirty_sector_count = responses
34253        .iter()
34254        .map(|response| response.coverage.dirty_sector_count)
34255        .sum();
34256    let completed_dirty_sector_count = responses
34257        .iter()
34258        .map(|response| response.coverage.completed_dirty_sector_count)
34259        .sum();
34260    let rebuilding_sector_count = responses
34261        .iter()
34262        .map(|response| response.coverage.rebuilding_sector_count)
34263        .sum();
34264    let resumed_sector_count = responses
34265        .iter()
34266        .map(|response| response.coverage.resumed_sector_count)
34267        .sum();
34268
34269    let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
34270        sift::SearchCoverageMode::Sealed
34271    } else if completed_dirty_sector_count > 0
34272        || rebuilding_sector_count > 0
34273        || resumed_sector_count > 0
34274    {
34275        sift::SearchCoverageMode::Converging
34276    } else {
34277        sift::SearchCoverageMode::Frontier
34278    };
34279
34280    sift::SearchCoverageSnapshot {
34281        mode,
34282        total_sector_count,
34283        mounted_sector_count,
34284        reused_sector_count,
34285        dirty_sector_count,
34286        completed_dirty_sector_count,
34287        rebuilding_sector_count,
34288        resumed_sector_count,
34289        active_rebuild: responses
34290            .iter()
34291            .find_map(|response| response.coverage.active_rebuild.clone()),
34292    }
34293}
34294
34295fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
34296    sift::SearchResponse {
34297        strategy: strategy.to_string(),
34298        root: root.display().to_string(),
34299        indexed_artifacts: 0,
34300        skipped_artifacts: 0,
34301        coverage: empty_search_coverage(),
34302        hits: Vec::new(),
34303    }
34304}
34305
34306fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
34307    for hit in &mut response.hits {
34308        let path = Path::new(&hit.path);
34309        if path.is_relative() {
34310            hit.path = search_root.join(path).display().to_string();
34311        }
34312    }
34313}
34314
34315fn merge_search_responses(
34316    root: &Path,
34317    strategy: &str,
34318    limit: usize,
34319    responses: Vec<sift::SearchResponse>,
34320) -> sift::SearchResponse {
34321    let indexed_artifacts = responses
34322        .iter()
34323        .map(|response| response.indexed_artifacts)
34324        .sum();
34325    let skipped_artifacts = responses
34326        .iter()
34327        .map(|response| response.skipped_artifacts)
34328        .sum();
34329    let coverage = if responses.is_empty() {
34330        empty_search_coverage()
34331    } else {
34332        aggregate_search_coverage(&responses)
34333    };
34334    let mut hits: Vec<sift::SearchHit> = responses
34335        .into_iter()
34336        .flat_map(|response| response.hits)
34337        .collect();
34338    hits.sort_by(|left, right| {
34339        right
34340            .score
34341            .partial_cmp(&left.score)
34342            .unwrap_or(Ordering::Equal)
34343            .then_with(|| left.path.cmp(&right.path))
34344            .then_with(|| left.location.cmp(&right.location))
34345    });
34346    hits.truncate(limit);
34347    for (rank, hit) in hits.iter_mut().enumerate() {
34348        hit.rank = rank + 1;
34349    }
34350
34351    sift::SearchResponse {
34352        strategy: strategy.to_string(),
34353        root: root.display().to_string(),
34354        indexed_artifacts,
34355        skipped_artifacts,
34356        coverage,
34357        hits,
34358    }
34359}
34360
34361pub(crate) fn federated_sift_search(
34362    root: &Path,
34363    cache_dir: &Path,
34364    query: &str,
34365    limit: usize,
34366    timeout_secs: u64,
34367    strategy: &str,
34368    fts_index_fresh: Option<bool>,
34369) -> Result<sift::SearchResponse> {
34370    let targets = resolve_search_index_targets(root, root, None, true)?;
34371    if targets.is_empty() {
34372        if config::Config::submodule_dirs(root)?.is_empty() {
34373            return run_search_with_timeout(
34374                root,
34375                cache_dir,
34376                query,
34377                limit,
34378                timeout_secs,
34379                strategy,
34380                &[],
34381                fts_index_fresh,
34382            );
34383        }
34384        return Ok(empty_search_response(root, strategy));
34385    }
34386
34387    let mut responses = Vec::with_capacity(targets.len());
34388    for target in &targets {
34389        let mut response = run_search_with_timeout(
34390            &target.source_root,
34391            cache_dir,
34392            query,
34393            limit,
34394            timeout_secs,
34395            strategy,
34396            std::slice::from_ref(target),
34397            fts_index_fresh,
34398        )?;
34399        absolutize_search_hit_paths(&mut response, &target.source_root);
34400        response.root = root.display().to_string();
34401        responses.push(response);
34402    }
34403
34404    Ok(merge_search_responses(root, strategy, limit, responses))
34405}
34406
34407/// Federated symbol search across every scoped `.tsift/indexes/<scope>/index.db`
34408/// in the workspace. Per-scope tagpath annotation runs inside the per-scope
34409/// loop so each scope's adapter resolves against its own `.naming.toml` /
34410/// `.naming/index.json` (the workspace root usually has no tagpath of its
34411/// own). The merged `TagpathAnnotationDiagnostic` reports `loaded=true` when
34412/// at least one scope loaded, and `stale=true` with the first stale reason
34413/// when any scope was stale.
34414pub(crate) fn federated_symbol_search(
34415    root: &std::path::Path,
34416    query: &str,
34417    limit: usize,
34418    tagpath_opts: &TagpathSearchOpts,
34419) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
34420    let cfg = config::Config::load(root)?;
34421    let submodules = config::Config::submodule_dirs(root)?;
34422    let mut all_hits: Vec<index::SymbolHit> = Vec::new();
34423    let mut combined = TagpathAnnotationDiagnostic::default();
34424    for scope in &submodules {
34425        if !cfg.federation_for_scope(scope) {
34426            continue;
34427        }
34428        let db_path = cfg.db_path_for(root, &scope.id);
34429        if !db_path.exists() {
34430            continue;
34431        }
34432        let db = index::IndexDb::open_read_only(&db_path)?;
34433        let mut hits = db.symbol_search(query, limit)?;
34434        let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
34435        combined.loaded |= diag.loaded;
34436        if diag.stale && !combined.stale {
34437            combined.stale = true;
34438            combined.reason = diag.reason;
34439        }
34440        all_hits.append(&mut hits);
34441    }
34442    all_hits.sort_by(|a, b| {
34443        b.score
34444            .partial_cmp(&a.score)
34445            .unwrap_or(std::cmp::Ordering::Equal)
34446    });
34447    all_hits.truncate(limit);
34448    Ok((all_hits, combined))
34449}
34450
34451#[derive(Debug, Deserialize)]
34452#[serde(tag = "type", rename_all = "lowercase")]
34453enum RipgrepJsonEvent {
34454    Match {
34455        data: RipgrepMatchData,
34456    },
34457    #[serde(other)]
34458    Other,
34459}
34460
34461#[derive(Debug, Deserialize)]
34462struct RipgrepMatchData {
34463    path: RipgrepTextField,
34464    lines: RipgrepTextField,
34465    line_number: Option<usize>,
34466}
34467
34468#[derive(Debug, Deserialize)]
34469struct RipgrepTextField {
34470    text: Option<String>,
34471}
34472
34473pub(crate) fn federated_exact_search(
34474    root: &Path,
34475    query: &str,
34476    limit: usize,
34477    timeout_secs: u64,
34478) -> Result<sift::SearchResponse> {
34479    let cfg = config::Config::load(root)?;
34480    let mut responses = Vec::new();
34481    for scope in config::Config::submodule_dirs(root)? {
34482        if !cfg.federation_for_scope(&scope) {
34483            continue;
34484        }
34485        let mut response =
34486            run_exact_search_with_timeout(std::slice::from_ref(&scope.source_root), query, limit, timeout_secs)?;
34487        absolutize_search_hit_paths(&mut response, &scope.source_root);
34488        response.root = root.display().to_string();
34489        responses.push(response);
34490    }
34491
34492    Ok(merge_search_responses(root, "exact", limit, responses))
34493}
34494
34495pub(crate) fn run_sift_search(
34496    search_path: &Path,
34497    cache_dir: &Path,
34498    query: &str,
34499    limit: usize,
34500    strategy: &str,
34501    // #015t Phase 4b — the caller's already-known FTS index freshness:
34502    //   `Some(true)`  — caller (cmd_search after precheck+autoindex) proved fresh;
34503    //                   use FTS without re-walking the tree.
34504    //   `Some(false)` — caller proved stale/degraded (e.g. read-only writer lock);
34505    //                   skip FTS, serve live results via the TokenIndex fallback.
34506    //   `None`        — unknown (direct programmatic callers); inspect here.
34507    // This drops the redundant `inspect_read_only` walk on the normal CLI path,
34508    // where `precheck_search_indexes` already established freshness.
34509    fts_index_fresh: Option<bool>,
34510) -> Result<sift::SearchResponse> {
34511    // #015t Phase 4 cutover: the `index.db` FTS5 path is now the DEFAULT for
34512    // lexical search. The normal search flow runs `precheck_search_indexes` with
34513    // autoindex first, so a fresh root `index.db` is guaranteed present before we
34514    // get here; the FTS5 BM25 path supersedes the parallel JSON `TokenIndex`
34515    // (which never rebuilt on content change — staleness was keyed on file
34516    // existence only). Ranking shifts from substring-position to BM25 by design;
34517    // the Phase 3 soundness gate proved candidate coverage (FTS ⊇ TokenIndex).
34518    //
34519    // The JSON `TokenIndex` is demoted to a FALLBACK for the only remaining cases
34520    // that reach here without a fresh root index.db: an un-indexed root reached
34521    // with `--no-autoindex` (the normal precheck degrades a missing index to exact
34522    // search, not lexical), a stale/degraded index (live results via TokenIndex),
34523    // and direct programmatic callers. `TSIFT_FTS_SEARCH=0` (`0`/`false`/`no`/`off`)
34524    // forces that legacy path as a transition escape hatch. Both the in-process
34525    // (timeout=0) and `__search-worker` subprocess routes pass through here; the
34526    // worker inherits the env var and is handed the freshness verdict explicitly.
34527    if !fts_search_forced_off() {
34528        let db_path = search_path.join(".tsift/index.db");
34529        let use_fts = match fts_index_fresh {
34530            Some(fresh) => fresh && db_path.exists(),
34531            None => db_path.exists() && index_db_is_fresh_for_fts(&db_path, search_path),
34532        };
34533        if use_fts {
34534            return sift::fts_search(&db_path, search_path, query, limit)
34535                .context("index.db FTS5 search failed");
34536        }
34537    }
34538
34539    let engine = Sift::builder().with_cache_dir(cache_dir).build();
34540    let options = SearchOptions::default()
34541        .with_limit(limit)
34542        .with_strategy(strategy.to_string());
34543    let input = SearchInput::new(search_path, query).with_options(options);
34544    engine.search(input).context("sift search failed")
34545}
34546
34547/// #015t Phase 4 — the FTS5 `index.db` path is trustworthy only when the index is
34548/// **openable AND fresh** for the search root. A missing/corrupt index.db (e.g. an
34549/// empty placeholder) or a **stale** one (e.g. held open by a concurrent writer so
34550/// autoindex degraded to read-only) falls back to the live `TokenIndex` path — so a
34551/// search never returns content the index has not caught up to. The normal flow's
34552/// `precheck_search_indexes` + autoindex makes this true in the common case;
34553/// re-inspecting here is a redundant tree-walk that #015t Phase 4b can replace by
34554/// threading the precheck's freshness result through to this call.
34555fn index_db_is_fresh_for_fts(db_path: &Path, search_path: &Path) -> bool {
34556    match index::IndexDb::inspect_read_only(db_path, search_path, false) {
34557        Ok(inspection) => {
34558            inspection.summary.new + inspection.summary.modified + inspection.summary.deleted == 0
34559        }
34560        Err(_) => false,
34561    }
34562}
34563
34564/// #015t Phase 4 — whether the operator has forced lexical search back onto the
34565/// legacy JSON `TokenIndex` path via `TSIFT_FTS_SEARCH` set to a falsy value
34566/// (`0`/`false`/`no`/`off`). The FTS5 `index.db` path is the default; this is the
34567/// transition escape hatch. Any other value (or unset) keeps the FTS5 default.
34568fn fts_search_forced_off() -> bool {
34569    std::env::var("TSIFT_FTS_SEARCH")
34570        .map(|value| fts_flag_value_disabled(&value))
34571        .unwrap_or(false)
34572}
34573
34574/// Pure parser for a falsy `TSIFT_FTS_SEARCH` value (factored out so the rules
34575/// can be unit-tested without mutating process env).
34576fn fts_flag_value_disabled(value: &str) -> bool {
34577    matches!(
34578        value.trim().to_ascii_lowercase().as_str(),
34579        "0" | "false" | "no" | "off"
34580    )
34581}
34582
34583fn exact_search_timeout_message(timeout_secs: u64) -> String {
34584    format!(
34585        "tsift search timed out after {}s (strategy: exact). \
34586         Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
34587        timeout_secs
34588    )
34589}
34590
34591fn exact_search_command(search_paths: &[PathBuf], query: &str) -> Command {
34592    let mut command = Command::new("rg");
34593    command
34594        .arg("--json")
34595        .arg("--fixed-strings")
34596        .arg("--line-number")
34597        .arg("--hidden")
34598        .arg("--")
34599        .arg(query);
34600    if search_paths.is_empty() {
34601        command.arg(Path::new("."));
34602    } else {
34603        command.args(search_paths);
34604    }
34605    command
34606}
34607
34608fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
34609    let observed_unix_secs = SystemTime::now()
34610        .duration_since(UNIX_EPOCH)
34611        .unwrap_or_default()
34612        .as_secs() as i64;
34613    let modified_unix_secs = fs::metadata(path)
34614        .ok()
34615        .and_then(|metadata| metadata.modified().ok())
34616        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
34617        .map(|duration| duration.as_secs() as i64);
34618    sift::ArtifactFreshness {
34619        observed_unix_secs,
34620        modified_unix_secs,
34621    }
34622}
34623
34624fn parse_exact_search_output(
34625    search_path: &Path,
34626    limit: usize,
34627    raw: &str,
34628) -> Result<sift::SearchResponse> {
34629    if limit == 0 {
34630        return Ok(sift::SearchResponse {
34631            strategy: "exact".to_string(),
34632            root: search_path.display().to_string(),
34633            indexed_artifacts: 0,
34634            skipped_artifacts: 0,
34635            coverage: empty_search_coverage(),
34636            hits: Vec::new(),
34637        });
34638    }
34639
34640    let mut hits = Vec::new();
34641    for line in raw.lines() {
34642        let event: RipgrepJsonEvent =
34643            serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
34644        let RipgrepJsonEvent::Match { data } = event else {
34645            continue;
34646        };
34647        let Some(path_text) = data.path.text else {
34648            continue;
34649        };
34650        let Some(lines_text) = data.lines.text else {
34651            continue;
34652        };
34653        let path = PathBuf::from(path_text);
34654        let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
34655        let rank = hits.len() + 1;
34656        hits.push(sift::SearchHit {
34657            artifact_id: format!(
34658                "exact:{}:{}:{}",
34659                path.display(),
34660                data.line_number.unwrap_or(0),
34661                rank
34662            ),
34663            artifact_kind: sift::ContextArtifactKind::File,
34664            path: path.display().to_string(),
34665            rank,
34666            score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
34667            confidence: sift::ScoreConfidence::High,
34668            location: data.line_number.map(|line| format!("line {}", line)),
34669            snippet: snippet.clone(),
34670            provenance: sift::ArtifactProvenance {
34671                adapter: sift::AcquisitionAdapterKind::FileSystem,
34672                source: "ripgrep -F".to_string(),
34673                synthetic: false,
34674            },
34675            freshness: exact_search_file_timestamp(&path),
34676            budget: sift::ArtifactBudget::from_text(&snippet, 1),
34677        });
34678        if hits.len() >= limit {
34679            break;
34680        }
34681    }
34682
34683    Ok(sift::SearchResponse {
34684        strategy: "exact".to_string(),
34685        root: search_path.display().to_string(),
34686        indexed_artifacts: hits.len(),
34687        skipped_artifacts: 0,
34688        coverage: empty_search_coverage(),
34689        hits,
34690    })
34691}
34692
34693fn exact_search_response_from_process(
34694    search_path: &Path,
34695    limit: usize,
34696    status: std::process::ExitStatus,
34697    stdout: &[u8],
34698    stderr: &[u8],
34699) -> Result<sift::SearchResponse> {
34700    if !status.success() && status.code() != Some(1) {
34701        let message = String::from_utf8_lossy(stderr);
34702        let trimmed = message.trim();
34703        if trimmed.is_empty() {
34704            bail!("ripgrep exact search exited with status {}", status);
34705        }
34706        bail!("{}", trimmed);
34707    }
34708
34709    let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
34710    parse_exact_search_output(search_path, limit, &raw)
34711}
34712
34713fn run_exact_search(search_paths: &[PathBuf], query: &str, limit: usize) -> Result<sift::SearchResponse> {
34714    let output = exact_search_command(search_paths, query)
34715        .output()
34716        .context("running exact search with ripgrep")?;
34717    let root_display = search_paths
34718        .first()
34719        .map(|p| p.as_path())
34720        .unwrap_or_else(|| Path::new("."));
34721    exact_search_response_from_process(
34722        root_display,
34723        limit,
34724        output.status,
34725        &output.stdout,
34726        &output.stderr,
34727    )
34728}
34729
34730pub(crate) fn run_exact_search_with_timeout(
34731    search_paths: &[PathBuf],
34732    query: &str,
34733    limit: usize,
34734    timeout_secs: u64,
34735) -> Result<sift::SearchResponse> {
34736    if timeout_secs == 0 {
34737        return run_exact_search(search_paths, query, limit);
34738    }
34739
34740    let mut child = exact_search_command(search_paths, query)
34741        .stdin(Stdio::null())
34742        .stdout(Stdio::piped())
34743        .stderr(Stdio::piped())
34744        .spawn()
34745        .context("spawning timed exact search worker")?;
34746
34747    let timeout = Duration::from_secs(timeout_secs);
34748    let status = wait_for_child_exit(&mut child, timeout)
34749        .context("waiting for timed exact search worker")?;
34750    if status.is_none() {
34751        let _ = child.kill();
34752        let _ = child.wait();
34753        bail!("{}", exact_search_timeout_message(timeout_secs));
34754    }
34755
34756    let status = status.unwrap();
34757    let stdout = read_child_stdout(&mut child)?;
34758    let stderr = read_child_stderr(&mut child)?;
34759    let root_display = search_paths
34760        .first()
34761        .map(|p| p.as_path())
34762        .unwrap_or_else(|| Path::new("."));
34763    exact_search_response_from_process(
34764        root_display,
34765        limit,
34766        status,
34767        stdout.as_bytes(),
34768        stderr.as_bytes(),
34769    )
34770}
34771
34772#[allow(clippy::too_many_arguments)]
34773pub(crate) fn run_search_with_timeout(
34774    search_path: &Path,
34775    cache_dir: &Path,
34776    query: &str,
34777    limit: usize,
34778    timeout_secs: u64,
34779    strategy: &str,
34780    search_targets: &[SearchIndexTarget],
34781    // #015t Phase 4b — FTS index freshness verdict forwarded to the worker so it
34782    // skips the redundant `inspect_read_only` walk (see `run_sift_search`).
34783    fts_index_fresh: Option<bool>,
34784) -> Result<sift::SearchResponse> {
34785    if timeout_secs == 0 {
34786        return run_sift_search(search_path, cache_dir, query, limit, strategy, fts_index_fresh);
34787    }
34788
34789    let output_path = next_search_worker_output_path();
34790    let mut command = Command::new(
34791        std::env::current_exe().context("resolving tsift executable for timed search")?,
34792    );
34793    command
34794        .arg("__search-worker")
34795        .arg("--path")
34796        .arg(search_path)
34797        .arg("--cache-dir")
34798        .arg(cache_dir)
34799        .arg("--query")
34800        .arg(query)
34801        .arg("--limit")
34802        .arg(limit.to_string())
34803        .arg("--strategy")
34804        .arg(strategy)
34805        .arg("--output")
34806        .arg(&output_path);
34807    if let Some(fresh) = fts_index_fresh {
34808        command.arg("--fts-index-fresh").arg(fresh.to_string());
34809    }
34810    let mut child = command
34811        .stdin(Stdio::null())
34812        .stdout(Stdio::null())
34813        .stderr(Stdio::piped())
34814        .spawn()
34815        .context("spawning timed sift search worker")?;
34816
34817    let timeout = Duration::from_secs(timeout_secs);
34818    let status =
34819        wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
34820    if status.is_none() {
34821        let _ = child.kill();
34822        let _ = child.wait();
34823        let _ = fs::remove_file(&output_path);
34824        bail!(
34825            "{}",
34826            search_timeout_message(timeout_secs, strategy, search_targets)?
34827        );
34828    }
34829
34830    let status = status.unwrap();
34831    let stderr = read_child_stderr(&mut child)?;
34832    if !status.success() {
34833        let _ = fs::remove_file(&output_path);
34834        let message = stderr.trim();
34835        if message.is_empty() {
34836            bail!("sift search worker exited with status {}", status);
34837        }
34838        bail!("{}", message);
34839    }
34840
34841    let raw = fs::read_to_string(&output_path)
34842        .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
34843    let _ = fs::remove_file(&output_path);
34844    serde_json::from_str(&raw).context("parsing search worker output")
34845}
34846
34847fn next_search_worker_output_path() -> PathBuf {
34848    let stamp = SystemTime::now()
34849        .duration_since(UNIX_EPOCH)
34850        .unwrap_or_default()
34851        .as_nanos();
34852    std::env::temp_dir().join(format!(
34853        "tsift-search-{}-{}.json",
34854        std::process::id(),
34855        stamp
34856    ))
34857}
34858
34859fn wait_for_child_exit(
34860    child: &mut std::process::Child,
34861    timeout: Duration,
34862) -> Result<Option<std::process::ExitStatus>> {
34863    let started = Instant::now();
34864    loop {
34865        if let Some(status) = child.try_wait()? {
34866            return Ok(Some(status));
34867        }
34868        if started.elapsed() >= timeout {
34869            return Ok(None);
34870        }
34871        let remaining = timeout.saturating_sub(started.elapsed());
34872        std::thread::sleep(remaining.min(Duration::from_millis(10)));
34873    }
34874}
34875
34876fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
34877    let mut stderr = String::new();
34878    if let Some(mut pipe) = child.stderr.take() {
34879        pipe.read_to_string(&mut stderr)
34880            .context("reading search worker stderr")?;
34881    }
34882    Ok(stderr)
34883}
34884
34885fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
34886    let mut stdout = String::new();
34887    if let Some(mut pipe) = child.stdout.take() {
34888        pipe.read_to_string(&mut stdout)
34889            .context("reading search worker stdout")?;
34890    }
34891    Ok(stdout)
34892}
34893
34894pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
34895    if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
34896        fs::write(&path, std::process::id().to_string())
34897            .with_context(|| format!("writing search worker pid file: {path}"))?;
34898    }
34899    if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
34900        let delay_ms = ms
34901            .parse::<u64>()
34902            .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
34903        std::thread::sleep(Duration::from_millis(delay_ms));
34904    }
34905    Ok(())
34906}
34907
34908#[cfg(test)]
34909thread_local! {
34910    static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
34911}
34912
34913#[cfg(test)]
34914enum SearchPostPrecheckLockMode {
34915    RollbackJournal,
34916    Wal,
34917}
34918
34919#[cfg(test)]
34920struct SearchPostPrecheckLockHook {
34921    db_path: PathBuf,
34922    mode: SearchPostPrecheckLockMode,
34923}
34924
34925#[cfg(test)]
34926struct SearchPostPrecheckLockGuard;
34927
34928#[cfg(test)]
34929impl Drop for SearchPostPrecheckLockGuard {
34930    fn drop(&mut self) {
34931        SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34932            hook.borrow_mut().take();
34933        });
34934    }
34935}
34936
34937#[cfg(test)]
34938fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34939    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
34940}
34941
34942#[cfg(test)]
34943fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34944    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
34945}
34946
34947#[cfg(test)]
34948fn install_search_post_precheck_lock_hook(
34949    db_path: PathBuf,
34950    mode: SearchPostPrecheckLockMode,
34951) -> SearchPostPrecheckLockGuard {
34952    SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34953        assert!(
34954            hook.borrow().is_none(),
34955            "search post-precheck lock hook already installed"
34956        );
34957        *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
34958    });
34959    SearchPostPrecheckLockGuard
34960}
34961
34962#[cfg(test)]
34963pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34964    let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
34965        return Ok(());
34966    };
34967    let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
34968    std::thread::spawn(move || {
34969        let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
34970        match hook.mode {
34971            SearchPostPrecheckLockMode::RollbackJournal => {
34972                conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
34973                    .expect("acquiring rollback-journal hook lock");
34974                fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
34975                    .expect("writing rollback journal marker");
34976            }
34977            SearchPostPrecheckLockMode::Wal => {
34978                conn.execute_batch(
34979                    "PRAGMA journal_mode=WAL;
34980                     PRAGMA wal_autocheckpoint=0;
34981                     CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
34982                     INSERT INTO search_wal_lock_probe DEFAULT VALUES;
34983                     PRAGMA locking_mode=EXCLUSIVE;
34984                     BEGIN EXCLUSIVE;",
34985                )
34986                .expect("acquiring WAL hook lock");
34987                assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
34988            }
34989        }
34990        ready_tx.send(()).expect("signaling search lock hook");
34991        std::thread::sleep(Duration::from_millis(200));
34992        drop(conn);
34993        let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
34994    });
34995    ready_rx
34996        .recv_timeout(Duration::from_secs(1))
34997        .context("waiting for search post-precheck lock hook")?;
34998    Ok(())
34999}
35000
35001#[cfg(not(test))]
35002pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
35003    Ok(())
35004}