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    Cli, Commands, DispatchTraceFormat, GraphDbQuery, KgCommand, LeaseCommand, LocalModelCommand,
86    SemanticRelatedKind, SourceReadStyle,
87};
88#[cfg(test)]
89use cli::{GraphDbBackend, TraverseFormat};
90use commands::digests::{
91    cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
92    cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
93};
94#[cfg(test)]
95use commands::graph::cmd_explain;
96use commands::graph::{
97    cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
98};
99#[cfg(test)]
100use commands::index_search::cmd_search;
101use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
102use commands::infra::{
103    StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
104    cmd_rewrite, cmd_route, cmd_sql, cmd_status,
105};
106use commands::memory::cmd_memory;
107use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
108use commands::summarize::cmd_summarize;
109use flate2::{Compression, read::GzDecoder, write::GzEncoder};
110#[cfg(test)]
111use output::ResponseBudgetPreset;
112use output::tagpath::{
113    TagpathAnnotationDiagnostic, TagpathSearchOpts, annotate_communities_with_tagpath,
114    annotate_hits_with_tagpath, annotate_path_nodes_with_tagpath,
115    annotate_stored_edges_with_tagpath, annotate_stored_symbols_with_tagpath,
116};
117use output::{
118    OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric, ToolEnvelopeSummary,
119    TranscriptArtifactRef,
120};
121use rusqlite::{Connection, OptionalExtension, Row};
122use serde::{Deserialize, Serialize};
123use sift::{SearchInput, SearchOptions, Sift};
124#[cfg(test)]
125use std::cell::RefCell;
126use std::cmp::Ordering;
127use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
128use std::env;
129use std::fs;
130use std::io::{Read as _, Write as _};
131use std::path::{Path, PathBuf};
132use std::process::{Command, Stdio};
133use std::sync::{Mutex, OnceLock};
134use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
135use substrate::{
136    ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
137    GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
138    GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
139    SqliteGraphStore, SqliteProjectionRefresh, TerseGraphEdge as SubstrateTerseGraphEdge,
140    TerseGraphNode as SubstrateTerseGraphNode,
141};
142use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
143#[cfg(test)]
144use tsift_agent_doc::session_cost;
145use tsift_agent_doc::session_markdown::{self, AgentDocQueueItem, AgentDocSessionDocument};
146#[cfg(test)]
147use tsift_agent_doc::session_review;
148use tsift_cache::cycle_packet_cache;
149use tsift_core::{
150    NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
151};
152use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
153use tsift_graph as graph;
154use tsift_index::{config, index, init, multiplicity, walk};
155use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
156#[cfg(test)]
157use tsift_memory::MemoryEvent;
158use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
159use tsift_resolution as resolution;
160use tsift_search::{impact, sift};
161use tsift_sqlite as substrate;
162use tsift_status::status;
163use tsift_summarize::summarize;
164#[cfg(feature = "backend-surrealdb")]
165use tsift_surrealdb::SurrealdbGraphStore;
166use tsift_tokensave::TokensaveDb;
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
169pub(crate) enum GraphDbExperimentalBackend {
170    DuckdbDuckpgq,
171    Falkordb,
172    Ladybug,
173    Kuzu,
174    Surrealdb,
175}
176
177#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
178pub(crate) struct SearchFacetFilters {
179    #[serde(skip_serializing_if = "Vec::is_empty", default)]
180    pub(crate) languages: Vec<String>,
181    #[serde(skip_serializing_if = "Vec::is_empty", default)]
182    pub(crate) kinds: Vec<String>,
183    #[serde(skip_serializing_if = "Vec::is_empty", default)]
184    pub(crate) node_kinds: Vec<String>,
185    #[serde(skip_serializing_if = "Vec::is_empty", default)]
186    pub(crate) sections: Vec<String>,
187    #[serde(skip_serializing_if = "Vec::is_empty", default)]
188    pub(crate) parents: Vec<String>,
189    #[serde(skip_serializing_if = "Vec::is_empty", default)]
190    pub(crate) children: Vec<String>,
191    #[serde(skip_serializing_if = "Vec::is_empty", default)]
192    pub(crate) fence_languages: Vec<String>,
193    #[serde(skip_serializing_if = "Vec::is_empty", default)]
194    pub(crate) list_depths: Vec<usize>,
195    #[serde(skip_serializing_if = "Vec::is_empty", default)]
196    pub(crate) heading_levels: Vec<usize>,
197}
198
199impl SearchFacetFilters {
200    pub(crate) fn is_empty(&self) -> bool {
201        self.languages.is_empty()
202            && self.kinds.is_empty()
203            && self.node_kinds.is_empty()
204            && self.sections.is_empty()
205            && self.parents.is_empty()
206            && self.children.is_empty()
207            && self.fence_languages.is_empty()
208            && self.list_depths.is_empty()
209            && self.heading_levels.is_empty()
210    }
211
212    fn needs_ast_context(&self) -> bool {
213        !self.sections.is_empty()
214            || !self.parents.is_empty()
215            || !self.children.is_empty()
216            || !self.fence_languages.is_empty()
217            || !self.list_depths.is_empty()
218            || !self.heading_levels.is_empty()
219    }
220}
221
222#[derive(Serialize)]
223struct GraphDbBackendPromotionGate {
224    status: String,
225    native_adapter_required: bool,
226    required_checks: Vec<String>,
227}
228
229impl GraphDbExperimentalBackend {
230    fn name(self) -> &'static str {
231        match self {
232            Self::DuckdbDuckpgq => "duckdb-duckpgq",
233            Self::Falkordb => "falkordb",
234            Self::Ladybug => "ladybug",
235            Self::Kuzu => "kuzu",
236            Self::Surrealdb => "surrealdb",
237        }
238    }
239
240    fn adapter_label(self) -> &'static str {
241        match self {
242            Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
243            Self::Falkordb => "FalkorDB read-only prototype",
244            Self::Ladybug => "Ladybug read-only prototype",
245            Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
246            Self::Surrealdb => "SurrealDB read-only prototype",
247        }
248    }
249
250    fn projection_load(self) -> &'static str {
251        match self {
252            Self::Falkordb => {
253                "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"
254            }
255            Self::Kuzu => {
256                "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"
257            }
258            Self::Surrealdb => {
259                "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"
260            }
261            _ => {
262                "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
263            }
264        }
265    }
266
267    fn lock_behavior(self) -> &'static str {
268        match self {
269            Self::Falkordb => {
270                "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
271            }
272            Self::Kuzu => {
273                "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"
274            }
275            Self::Surrealdb => {
276                "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
277            }
278            _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
279        }
280    }
281
282    fn install_portability(self) -> &'static str {
283        match self {
284            Self::Falkordb => {
285                "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
286            }
287            Self::Kuzu => {
288                "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"
289            }
290            Self::Surrealdb => {
291                "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"
292            }
293            _ => {
294                "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
295            }
296        }
297    }
298
299    fn prototype_hold_reason(self) -> Option<&'static str> {
300        match self {
301            Self::DuckdbDuckpgq => Some(
302                "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
303            ),
304            Self::Falkordb => Some(
305                "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",
306            ),
307            Self::Ladybug => Some(
308                "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
309            ),
310            Self::Kuzu => Some(
311                "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
312            ),
313            Self::Surrealdb => Some(
314                "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",
315            ),
316        }
317    }
318
319    fn promotion_gate(self) -> GraphDbBackendPromotionGate {
320        match self {
321            Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
322                status: "hold_native_adapter_required".to_string(),
323                native_adapter_required: true,
324                required_checks: vec![
325                    "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
326                        .to_string(),
327                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
328                        .to_string(),
329                    "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
330                    "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
331                        .to_string(),
332                ],
333            },
334            Self::Falkordb => GraphDbBackendPromotionGate {
335                status: "hold_native_adapter_required".to_string(),
336                native_adapter_required: true,
337                required_checks: vec![
338                    "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
339                        .to_string(),
340                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
341                        .to_string(),
342                    "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
343                        .to_string(),
344                    "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
345                        .to_string(),
346                ],
347            },
348            Self::Ladybug => GraphDbBackendPromotionGate {
349                status: "hold_native_adapter_required".to_string(),
350                native_adapter_required: true,
351                required_checks: vec![
352                    "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
353                        .to_string(),
354                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
355                        .to_string(),
356                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
357                        .to_string(),
358                    "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
359                        .to_string(),
360                ],
361            },
362            Self::Kuzu => GraphDbBackendPromotionGate {
363                status: "hold_native_adapter_required".to_string(),
364                native_adapter_required: true,
365                required_checks: vec![
366                    "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
367                        .to_string(),
368                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
369                        .to_string(),
370                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
371                        .to_string(),
372                    "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
373                        .to_string(),
374                ],
375            },
376            Self::Surrealdb => GraphDbBackendPromotionGate {
377                status: "hold_native_adapter_required".to_string(),
378                native_adapter_required: true,
379                required_checks: vec![
380                    "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
381                        .to_string(),
382                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
383                        .to_string(),
384                    "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
385                        .to_string(),
386                    "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
387                        .to_string(),
388                ],
389            },
390        }
391    }
392
393    fn parse(raw: &str) -> Result<Self> {
394        match raw {
395            "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
396            "falkordb" | "falkor" => Ok(Self::Falkordb),
397            "ladybug" => Ok(Self::Ladybug),
398            "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
399            "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
400            _ => {
401                bail!(
402                    "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
403                )
404            }
405        }
406    }
407}
408
409pub fn run() -> Result<()> {
410    let cli = Cli::parse();
411    let compact = cli.compact;
412    let pretty = cli.pretty;
413    let terse = cli.terse || cli.ultra_terse;
414    let ultra_terse = cli.ultra_terse;
415    let absolute = cli.absolute;
416    let tabular = cli.tabular;
417    let schema = cli.schema;
418    let envelope = cli.envelope;
419    match cli.command {
420        Some(Commands::Search {
421            query,
422            path,
423            limit,
424            strategy,
425            exact,
426            scope,
427            federated,
428            lang,
429            kind,
430            node_kind,
431            section,
432            parent,
433            child,
434            fence_language,
435            list_depth,
436            heading_level,
437            json,
438            autoindex,
439            no_autoindex,
440            timeout,
441            max_items,
442            max_bytes,
443            budget,
444            no_tagpath,
445            tagpath_strict,
446        }) => cmd_search_with_budget(
447            query,
448            path,
449            limit,
450            if exact {
451                Some("exact".to_string())
452            } else {
453                strategy
454            },
455            scope,
456            federated,
457            json || terse || schema || envelope,
458            autoindex || !no_autoindex,
459            timeout,
460            compact,
461            pretty,
462            terse,
463            ultra_terse,
464            absolute,
465            tabular,
466            schema,
467            envelope,
468            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
469            TagpathSearchOpts {
470                no_tagpath,
471                strict: tagpath_strict,
472            },
473            SearchFacetFilters {
474                languages: lang,
475                kinds: kind,
476                node_kinds: node_kind,
477                sections: section,
478                parents: parent,
479                children: child,
480                fence_languages: fence_language,
481                list_depths: list_depth,
482                heading_levels: heading_level,
483            },
484        ),
485        Some(Commands::SearchWorker {
486            path,
487            cache_dir,
488            query,
489            limit,
490            strategy,
491            output,
492            fts_index_fresh,
493        }) => cmd_search_worker(
494            &path,
495            &cache_dir,
496            &query,
497            limit,
498            &strategy,
499            &output,
500            fts_index_fresh,
501        ),
502        Some(Commands::DigestRunner {
503            kind,
504            path,
505            runner,
506            shell_command,
507            json,
508        }) => cmd_digest_runner(
509            &kind,
510            &path,
511            runner.as_deref(),
512            &shell_command,
513            OutputFormat {
514                json_output: json || terse || schema || envelope,
515                compact,
516                pretty,
517                terse,
518                ultra_terse,
519                schema,
520                envelope,
521            },
522        ),
523        Some(Commands::Edit { dry_run, file }) => {
524            cmd_edit(dry_run, file, compact, pretty, terse, schema)
525        }
526        Some(Commands::EditIntents {
527            path,
528            scope,
529            file,
530            json,
531            apply,
532            verify,
533            verify_command,
534            max_items,
535            max_bytes,
536            budget,
537        }) => cmd_edit_intents(
538            &path,
539            scope.as_deref(),
540            file,
541            apply,
542            SemanticEditVerifyOptions {
543                enabled: verify,
544                command: verify_command.as_deref(),
545            },
546            OutputFormat {
547                json_output: json || terse || schema || envelope,
548                compact,
549                pretty,
550                terse,
551                ultra_terse,
552                schema,
553                envelope,
554            },
555            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
556        ),
557        Some(Commands::Index {
558            path,
559            rebuild,
560            check,
561            exit_code,
562            prune,
563            quiet,
564            workspace,
565            submodule,
566            json,
567        }) => cmd_index(
568            &path,
569            rebuild,
570            check,
571            exit_code,
572            prune,
573            quiet,
574            workspace,
575            submodule.as_deref(),
576            json || terse || schema || envelope,
577            compact,
578            pretty,
579            terse,
580            absolute,
581            schema,
582        ),
583        Some(Commands::Rewrite { command, run }) => cmd_rewrite(
584            &command,
585            run,
586            OutputFormat {
587                json_output: terse || schema || envelope,
588                compact,
589                pretty,
590                terse,
591                ultra_terse,
592                schema,
593                envelope,
594            },
595        ),
596        Some(Commands::Route { task, id }) => cmd_route(&task, id),
597        Some(Commands::Memory { command }) => {
598            let json = command.json_output();
599            cmd_memory(
600                command,
601                OutputFormat {
602                    json_output: json || terse || schema || envelope,
603                    compact,
604                    pretty,
605                    terse,
606                    ultra_terse,
607                    schema,
608                    envelope,
609                },
610            )
611        }
612        Some(Commands::LocalModel { command }) => {
613            let json = command.json_output();
614            cmd_local_model(
615                command,
616                OutputFormat {
617                    json_output: json || terse || schema || envelope,
618                    compact,
619                    pretty,
620                    terse,
621                    ultra_terse,
622                    schema,
623                    envelope,
624                },
625            )
626        }
627        Some(Commands::Kg { command }) => match command {
628            KgCommand::Extract {
629                profile,
630                model,
631                host,
632                input,
633                source_ref,
634                graph_db,
635                no_lease,
636                idle_ttl_seconds,
637                keep_loaded,
638                lease_file,
639                no_context,
640                json,
641            } => commands::kg::cmd_kg_extract(commands::kg::KgExtractArgs {
642                profile,
643                model,
644                host,
645                input,
646                source_ref,
647                graph_db,
648                no_lease,
649                idle_ttl_seconds,
650                keep_loaded,
651                lease_file,
652                no_context,
653                json: json || terse || schema || envelope,
654            }),
655            KgCommand::Status { graph_db, json } => {
656                commands::kg::cmd_kg_status(graph_db, json || terse || schema || envelope)
657            }
658            KgCommand::Refresh {
659                graph_db,
660                apply,
661                profile,
662                model,
663                host,
664                no_lease,
665                idle_ttl_seconds,
666                keep_loaded,
667                lease_file,
668                no_context,
669                json,
670            } => commands::kg::cmd_kg_refresh(commands::kg::KgRefreshArgs {
671                graph_db,
672                json: json || terse || schema || envelope,
673                apply,
674                profile,
675                model,
676                host,
677                no_lease,
678                idle_ttl_seconds,
679                keep_loaded,
680                lease_file,
681                no_context,
682            }),
683            KgCommand::Evidence {
684                symbol,
685                kind,
686                limit,
687                graph_db,
688                json,
689            } => commands::kg::cmd_kg_evidence(
690                symbol,
691                kind,
692                limit,
693                graph_db,
694                json || terse || schema || envelope,
695            ),
696            KgCommand::Unload {
697                profile,
698                model,
699                host,
700                json,
701            } => commands::kg::cmd_kg_unload(
702                profile,
703                model,
704                host,
705                json || terse || schema || envelope,
706            ),
707            KgCommand::Smoke {
708                profile,
709                model,
710                host,
711                unload,
712                json,
713            } => commands::kg::cmd_kg_smoke(
714                profile,
715                model,
716                host,
717                unload,
718                json || terse || schema || envelope,
719            ),
720        },
721        Some(Commands::Finding { command }) => match command {
722            cli::FindingCommand::Add {
723                path,
724                kind,
725                title,
726                body,
727                about,
728                confidence,
729                status,
730                relates,
731                scope,
732                json,
733            } => commands::finding::cmd_finding_add(
734                &path,
735                &kind,
736                &title,
737                &body,
738                &about,
739                confidence,
740                &status,
741                relates.as_deref(),
742                scope.as_deref(),
743                json || terse || schema || envelope,
744                pretty,
745            ),
746            cli::FindingCommand::List {
747                path,
748                about,
749                kind,
750                status,
751                include_stale,
752                scope,
753                json,
754            } => commands::finding::cmd_finding_list(
755                &path,
756                about.as_deref(),
757                kind.as_deref(),
758                status.as_deref(),
759                include_stale,
760                scope.as_deref(),
761                json || terse || schema || envelope,
762                pretty,
763            ),
764            cli::FindingCommand::Harvest { path, scope, json } => {
765                commands::finding::cmd_finding_harvest(
766                    &path,
767                    scope.as_deref(),
768                    json || terse || schema || envelope,
769                    pretty,
770                )
771            }
772            cli::FindingCommand::Promote { id, path, json } => {
773                commands::finding::cmd_finding_promote(
774                    &path,
775                    &id,
776                    json || terse || schema || envelope,
777                    pretty,
778                )
779            }
780        },
781        Some(Commands::Graph {
782            symbol,
783            path,
784            callers,
785            callees,
786            scope,
787            limit,
788            json,
789            no_tagpath,
790            tagpath_strict,
791        }) => cmd_graph(
792            &symbol,
793            &path,
794            callers,
795            callees,
796            scope.as_deref(),
797            limit,
798            json || terse || schema || envelope,
799            compact,
800            pretty,
801            terse,
802            absolute,
803            tabular,
804            schema,
805            TagpathSearchOpts {
806                no_tagpath,
807                strict: tagpath_strict,
808            },
809        ),
810        Some(Commands::Sql {
811            db,
812            query,
813            table,
814            json,
815        }) => cmd_sql(
816            &db,
817            query,
818            table,
819            json || terse || schema || envelope,
820            compact,
821            pretty,
822            terse,
823            schema,
824        ),
825        Some(Commands::Communities {
826            path,
827            scope,
828            min_size,
829            limit,
830            json,
831            no_tagpath,
832            tagpath_strict,
833        }) => cmd_communities(
834            &path,
835            scope.as_deref(),
836            min_size,
837            limit,
838            json || terse || schema || envelope,
839            compact,
840            pretty,
841            terse,
842            tabular,
843            schema,
844            TagpathSearchOpts {
845                no_tagpath,
846                strict: tagpath_strict,
847            },
848        ),
849        Some(Commands::Analyze {
850            path,
851            scope,
852            entry_points,
853            limit,
854            json,
855        }) => cmd_analyze(
856            &path,
857            scope.as_deref(),
858            &entry_points,
859            limit,
860            OutputFormat {
861                json_output: json || terse || schema || envelope,
862                compact,
863                pretty,
864                terse,
865                ultra_terse,
866                schema,
867                envelope,
868            },
869        ),
870        Some(Commands::Path {
871            from,
872            to,
873            path,
874            scope,
875            json,
876            no_tagpath,
877            tagpath_strict,
878        }) => cmd_path(
879            &from,
880            &to,
881            &path,
882            scope.as_deref(),
883            json || terse || schema || envelope,
884            compact,
885            pretty,
886            terse,
887            schema,
888            TagpathSearchOpts {
889                no_tagpath,
890                strict: tagpath_strict,
891            },
892        ),
893        Some(Commands::Explain {
894            symbol,
895            path,
896            scope,
897            limit,
898            json,
899            max_items,
900            max_bytes,
901            budget,
902            no_tagpath,
903            tagpath_strict,
904        }) => cmd_explain_with_budget(
905            &symbol,
906            &path,
907            scope.as_deref(),
908            limit,
909            json || terse || schema || envelope,
910            compact,
911            pretty,
912            terse,
913            ultra_terse,
914            absolute,
915            tabular,
916            schema,
917            envelope,
918            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
919            TagpathSearchOpts {
920                no_tagpath,
921                strict: tagpath_strict,
922            },
923        ),
924        Some(Commands::Traverse {
925            node,
926            to,
927            path,
928            scope,
929            depth,
930            limit,
931            format,
932            convex_snapshot,
933        }) => cmd_traverse(
934            node.as_deref(),
935            to.as_deref(),
936            &path,
937            scope.as_deref(),
938            depth,
939            limit,
940            format,
941            pretty,
942            terse,
943            schema,
944            convex_snapshot.as_deref(),
945        ),
946        Some(Commands::ConvexSync {
947            path,
948            scope,
949            snapshot,
950            chunk_size,
951            remote_snapshot,
952            apply,
953            endpoint,
954            auth_token_env,
955            json,
956        }) => cmd_convex_sync(
957            ConvexSyncOptions {
958                path: &path,
959                scope: scope.as_deref(),
960                snapshot: snapshot.as_deref(),
961                chunk_size,
962                remote_snapshot,
963                apply,
964                endpoint: endpoint.as_deref(),
965                auth_token_env: &auth_token_env,
966            },
967            OutputFormat {
968                json_output: json || terse || schema || envelope,
969                compact,
970                pretty,
971                terse,
972                ultra_terse,
973                schema,
974                envelope,
975            },
976        ),
977        Some(Commands::GraphDb {
978            path,
979            scope,
980            backend,
981            convex_snapshot,
982            json,
983            query,
984        }) => cmd_graph_db(
985            &path,
986            scope.as_deref(),
987            backend,
988            convex_snapshot.as_deref(),
989            query,
990            OutputFormat {
991                json_output: json || terse || schema || envelope,
992                compact,
993                pretty,
994                terse,
995                ultra_terse,
996                schema,
997                envelope,
998            },
999        ),
1000        Some(Commands::SourceRead {
1001            file,
1002            path,
1003            style,
1004            start,
1005            lines,
1006            end,
1007            scope,
1008            json,
1009            max_items,
1010            max_bytes,
1011            budget,
1012        }) => cmd_source_read(
1013            &file,
1014            &path,
1015            style,
1016            start,
1017            lines,
1018            end,
1019            scope.as_deref(),
1020            OutputFormat {
1021                json_output: json || terse || schema || envelope,
1022                compact,
1023                pretty,
1024                terse,
1025                ultra_terse,
1026                schema,
1027                envelope,
1028            },
1029            absolute,
1030            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1031        ),
1032        Some(Commands::MarkdownAst {
1033            file,
1034            path,
1035            node,
1036            json,
1037            max_items,
1038            max_bytes,
1039            budget,
1040        }) => cmd_markdown_ast(
1041            &file,
1042            &path,
1043            node.as_deref(),
1044            OutputFormat {
1045                json_output: json || terse || schema || envelope,
1046                compact,
1047                pretty,
1048                terse,
1049                ultra_terse,
1050                schema,
1051                envelope,
1052            },
1053            absolute,
1054            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1055        ),
1056        Some(Commands::SymbolRead {
1057            symbol,
1058            file,
1059            path,
1060            scope,
1061            json,
1062            max_items,
1063            max_bytes,
1064            budget,
1065        }) => cmd_symbol_read(
1066            &symbol,
1067            file.as_deref(),
1068            &path,
1069            scope.as_deref(),
1070            OutputFormat {
1071                json_output: json || terse || schema || envelope,
1072                compact,
1073                pretty,
1074                terse,
1075                ultra_terse,
1076                schema,
1077                envelope,
1078            },
1079            absolute,
1080            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1081        ),
1082        Some(Commands::Audit {
1083            skills_dir,
1084            manifest,
1085            usage,
1086            cleanup,
1087            report,
1088            json,
1089        }) => cmd_audit(
1090            &skills_dir,
1091            manifest,
1092            usage,
1093            cleanup,
1094            report,
1095            json || terse || schema || envelope,
1096            compact,
1097            pretty,
1098            terse,
1099            schema,
1100        ),
1101        Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
1102            &path,
1103            scope.as_deref(),
1104            json || terse || schema || envelope,
1105            pretty,
1106            terse,
1107            schema,
1108        ),
1109        Some(Commands::Init {
1110            path,
1111            codex,
1112            opencode,
1113            workspace,
1114        }) => cmd_init(&path, codex, opencode, workspace),
1115        Some(Commands::Lint {
1116            file,
1117            index,
1118            entities_from,
1119            json,
1120        }) => cmd_lint(
1121            &file,
1122            index,
1123            entities_from,
1124            json || terse || schema || envelope,
1125            compact,
1126            pretty,
1127            terse,
1128            schema,
1129        ),
1130        Some(Commands::Summarize {
1131            symbol,
1132            file,
1133            extract,
1134            diff,
1135            stats,
1136            path,
1137            profile,
1138            json,
1139        }) => cmd_summarize(
1140            symbol,
1141            file,
1142            extract,
1143            diff,
1144            stats,
1145            &path,
1146            json || terse || schema || envelope,
1147            compact,
1148            pretty,
1149            terse,
1150            schema,
1151            profile,
1152        ),
1153        Some(Commands::Semantic {
1154            query,
1155            path,
1156            scope,
1157            limit,
1158            kind,
1159            profile,
1160            json,
1161        }) => cmd_semantic_related(
1162            &query,
1163            &path,
1164            scope.as_deref(),
1165            limit,
1166            kind,
1167            json || terse || schema || envelope,
1168            compact,
1169            pretty,
1170            terse,
1171            schema,
1172            profile,
1173        ),
1174        Some(Commands::DiffDigest {
1175            path,
1176            cached,
1177            revision,
1178            max_parsed_files,
1179            json,
1180        }) => cmd_diff_digest(
1181            &path,
1182            cached,
1183            revision.as_deref(),
1184            max_parsed_files,
1185            OutputFormat {
1186                json_output: json || terse || schema || envelope,
1187                compact,
1188                pretty,
1189                terse,
1190                ultra_terse,
1191                schema,
1192                envelope,
1193            },
1194        ),
1195        Some(Commands::Impact {
1196            path,
1197            cached,
1198            revision,
1199            scope,
1200            limit,
1201            json,
1202        }) => cmd_impact(
1203            &path,
1204            cached,
1205            revision.as_deref(),
1206            scope.as_deref(),
1207            limit,
1208            OutputFormat {
1209                json_output: json || terse || schema || envelope,
1210                compact,
1211                pretty,
1212                terse,
1213                ultra_terse,
1214                schema,
1215                envelope,
1216            },
1217        ),
1218        Some(Commands::TestDigest {
1219            path,
1220            input,
1221            runner,
1222            json,
1223        }) => cmd_test_digest(
1224            &path,
1225            input.as_deref(),
1226            runner.as_deref(),
1227            OutputFormat {
1228                json_output: json || terse || schema || envelope,
1229                compact,
1230                pretty,
1231                terse,
1232                ultra_terse,
1233                schema,
1234                envelope,
1235            },
1236        ),
1237        Some(Commands::LogDigest {
1238            path,
1239            input,
1240            fixture,
1241            fail_under,
1242            json,
1243        }) => cmd_log_digest(
1244            &path,
1245            input.as_deref(),
1246            fixture.as_deref(),
1247            fail_under,
1248            OutputFormat {
1249                json_output: json || terse || schema || envelope,
1250                compact,
1251                pretty,
1252                terse,
1253                ultra_terse,
1254                schema,
1255                envelope,
1256            },
1257        ),
1258        Some(Commands::ContextPack {
1259            path,
1260            test_input,
1261            runner,
1262            log_input,
1263            json,
1264            max_items,
1265            max_bytes,
1266            budget,
1267            convex_snapshot,
1268        }) => cmd_context_pack(
1269            &path,
1270            test_input.as_deref(),
1271            runner.as_deref(),
1272            log_input.as_deref(),
1273            OutputFormat {
1274                json_output: json || terse || schema || envelope,
1275                compact,
1276                pretty,
1277                terse,
1278                ultra_terse,
1279                schema,
1280                envelope,
1281            },
1282            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1283            convex_snapshot.as_deref(),
1284        ),
1285        Some(Commands::ConflictMatrix {
1286            targets,
1287            path,
1288            scope,
1289            depth,
1290            limit,
1291            impact_limit,
1292            json,
1293        }) => cmd_conflict_matrix(
1294            &path,
1295            scope.as_deref(),
1296            &targets,
1297            depth,
1298            limit,
1299            impact_limit,
1300            OutputFormat {
1301                json_output: json || terse || schema || envelope,
1302                compact,
1303                pretty,
1304                terse,
1305                ultra_terse,
1306                schema,
1307                envelope,
1308            },
1309        ),
1310        Some(Commands::DispatchTrace {
1311            targets,
1312            path,
1313            scope,
1314            depth,
1315            limit,
1316            impact_limit,
1317            format,
1318            json,
1319        }) => cmd_dispatch_trace(
1320            DispatchTraceOptions {
1321                path: &path,
1322                scope: scope.as_deref(),
1323                raw_targets: &targets,
1324                depth,
1325                limit,
1326                impact_limit,
1327                trace_format: if json {
1328                    DispatchTraceFormat::Json
1329                } else {
1330                    format
1331                },
1332            },
1333            OutputFormat {
1334                json_output: json || terse || schema || envelope,
1335                compact,
1336                pretty,
1337                terse,
1338                ultra_terse,
1339                schema,
1340                envelope,
1341            },
1342        ),
1343        Some(Commands::DependencyDag {
1344            targets,
1345            path,
1346            scope,
1347            depth,
1348            limit,
1349            json,
1350        }) => cmd_dependency_dag(
1351            &path,
1352            scope.as_deref(),
1353            &targets,
1354            depth,
1355            limit,
1356            OutputFormat {
1357                json_output: json || terse || schema || envelope,
1358                compact,
1359                pretty,
1360                terse,
1361                ultra_terse,
1362                schema,
1363                envelope,
1364            },
1365        ),
1366        Some(Commands::TokenSavings {
1367            fixture,
1368            fail_under,
1369            json,
1370        }) => token_savings::cmd_token_savings(
1371            &fixture,
1372            fail_under,
1373            OutputFormat {
1374                json_output: json || terse || schema || envelope,
1375                compact,
1376                pretty,
1377                terse,
1378                ultra_terse,
1379                schema,
1380                envelope,
1381            },
1382        ),
1383        Some(Commands::MetricDigest {
1384            input,
1385            baseline,
1386            metrics,
1387            lower_is_better,
1388            higher_is_better,
1389            history,
1390            top,
1391            json,
1392        }) => cmd_metric_digest(
1393            MetricDigestOptions {
1394                input_path: input.as_deref(),
1395                baseline_path: baseline.as_deref(),
1396                metrics: &metrics,
1397                lower_is_better: &lower_is_better,
1398                higher_is_better: &higher_is_better,
1399                history,
1400                top,
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::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1413            &fixture,
1414            OutputFormat {
1415                json_output: json || terse || schema || envelope,
1416                compact,
1417                pretty,
1418                terse,
1419                ultra_terse,
1420                schema,
1421                envelope,
1422            },
1423        ),
1424        Some(Commands::TokenGate { command }) => {
1425            cmd_token_gate(
1426                command,
1427                OutputFormat {
1428                    json_output: true,
1429                    compact,
1430                    pretty,
1431                    terse,
1432                    ultra_terse,
1433                    schema,
1434                    envelope,
1435                },
1436            )?;
1437            Ok(())
1438        }
1439        Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1440            &topic,
1441            OutputFormat {
1442                json_output: json || terse || schema || envelope,
1443                compact,
1444                pretty,
1445                terse,
1446                ultra_terse,
1447                schema,
1448                envelope,
1449            },
1450        ),
1451        Some(Commands::SessionDigest {
1452            path,
1453            input,
1454            source,
1455            json,
1456        }) => cmd_session_digest(
1457            &path,
1458            input.as_deref(),
1459            source.as_deref(),
1460            OutputFormat {
1461                json_output: json || terse || schema || envelope,
1462                compact,
1463                pretty,
1464                terse,
1465                ultra_terse,
1466                schema,
1467                envelope,
1468            },
1469        ),
1470        Some(Commands::SessionCost {
1471            input,
1472            fixture,
1473            fail_under,
1474            source,
1475            json,
1476        }) => cmd_session_cost(
1477            input.as_deref(),
1478            fixture.as_deref(),
1479            fail_under,
1480            source.as_deref(),
1481            OutputFormat {
1482                json_output: json || terse || schema || envelope,
1483                compact,
1484                pretty,
1485                terse,
1486                ultra_terse,
1487                schema,
1488                envelope,
1489            },
1490        ),
1491        Some(Commands::SessionReview {
1492            path,
1493            next_context,
1494            json,
1495            max_items,
1496            max_bytes,
1497            budget,
1498        }) => cmd_session_review_with_budget(
1499            &path,
1500            next_context,
1501            OutputFormat {
1502                json_output: json || terse || schema || envelope,
1503                compact,
1504                pretty,
1505                terse,
1506                ultra_terse,
1507                schema,
1508                envelope,
1509            },
1510            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1511        ),
1512        Some(Commands::Status {
1513            path,
1514            fix,
1515            no_fix,
1516            json,
1517        }) => cmd_status(
1518            &path,
1519            StatusCommandOptions {
1520                fix,
1521                no_fix,
1522                json_output: json || terse || schema || envelope,
1523                compact,
1524                pretty,
1525                terse,
1526                schema,
1527            },
1528        ),
1529        Some(Commands::Locks { path, scope, json }) => cmd_locks(
1530            &path,
1531            scope.as_deref(),
1532            json || terse || schema || envelope,
1533            compact,
1534            pretty,
1535            terse,
1536            schema,
1537        ),
1538        None => {
1539            println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1540            println!("Run `tsift --help` for usage.");
1541            Ok(())
1542        }
1543    }
1544}
1545
1546fn cmd_local_model(command: LocalModelCommand, output: OutputFormat) -> Result<()> {
1547    match command {
1548        LocalModelCommand::Status { no_probe, .. } => {
1549            let report = tsift_local_model::build_status_report(!no_probe);
1550            if output.json_output {
1551                if output.pretty {
1552                    println!("{}", serde_json::to_string_pretty(&report)?);
1553                } else {
1554                    println!("{}", serde_json::to_string(&report)?);
1555                }
1556            } else {
1557                print!("{}", tsift_local_model::format_status_human(&report));
1558            }
1559            Ok(())
1560        }
1561        LocalModelCommand::Unload {
1562            profile,
1563            provider_endpoint,
1564            provider_pid,
1565            idle_ttl_seconds,
1566            no_probe,
1567            pre_used_mib,
1568            post_used_mib,
1569            tolerance_mib,
1570            strict,
1571            ..
1572        } => {
1573            let profile = tsift_local_model::profile_by_id(&profile)
1574                .with_context(|| format!("unknown local model profile {profile:?}"))?;
1575            let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1576            let post_probe =
1577                lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1578            let report = tsift_local_model::build_lifecycle_report(
1579                profile,
1580                pre_probe,
1581                post_probe,
1582                provider_endpoint,
1583                provider_pid,
1584                idle_ttl_seconds,
1585                tolerance_mib,
1586            );
1587            if output.json_output {
1588                if output.pretty {
1589                    println!("{}", serde_json::to_string_pretty(&report)?);
1590                } else {
1591                    println!("{}", serde_json::to_string(&report)?);
1592                }
1593            } else {
1594                print!("{}", tsift_local_model::format_lifecycle_human(&report));
1595            }
1596            if strict && !report.cleanup.cleanup_proven {
1597                bail!(
1598                    "local model VRAM cleanup was not proven: {}",
1599                    report.cleanup.reason
1600                );
1601            }
1602            Ok(())
1603        }
1604        LocalModelCommand::Lease { command } => cmd_local_model_lease(command, output),
1605        LocalModelCommand::Resolve {
1606            profile,
1607            role,
1608            no_probe,
1609            ..
1610        } => {
1611            let preference_value = profile.as_deref();
1612            let preference = tsift_local_model::ProfilePreference::from_cli(preference_value);
1613            let probe = if no_probe {
1614                tsift_local_model::GpuProbe::unavailable("gpu probe skipped")
1615            } else {
1616                tsift_local_model::probe_nvidia_smi()
1617            };
1618            let resolution = tsift_local_model::resolve_profile_preference(
1619                &preference,
1620                role.to_model_role(),
1621                &probe,
1622            );
1623            if output.json_output {
1624                if output.pretty {
1625                    println!("{}", serde_json::to_string_pretty(&resolution)?);
1626                } else {
1627                    println!("{}", serde_json::to_string(&resolution)?);
1628                }
1629            } else {
1630                println!(
1631                    "preference: {} | role: {:?}",
1632                    preference.describe(),
1633                    role.to_model_role()
1634                );
1635                println!(
1636                    "selected: {} ({})",
1637                    resolution.profile.id, resolution.profile.label
1638                );
1639                println!("selectable: {}", resolution.selectable);
1640                println!("source: {:?}", resolution.source);
1641                println!("reason: {}", resolution.reason);
1642            }
1643            Ok(())
1644        }
1645        LocalModelCommand::Swap {
1646            from,
1647            to,
1648            provider_endpoint,
1649            provider_pid,
1650            idle_ttl_seconds,
1651            no_probe,
1652            pre_used_mib,
1653            post_used_mib,
1654            tolerance_mib,
1655            strict,
1656            ..
1657        } => {
1658            let from_profile = tsift_local_model::profile_by_id(&from)
1659                .with_context(|| format!("unknown source local model profile {from:?}"))?;
1660            let to_profile = tsift_local_model::profile_by_id(&to)
1661                .with_context(|| format!("unknown target local model profile {to:?}"))?;
1662            let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1663            let post_probe =
1664                lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1665            let report = tsift_local_model::build_swap_report(
1666                from_profile,
1667                to_profile,
1668                pre_probe,
1669                post_probe,
1670                provider_endpoint,
1671                provider_pid,
1672                idle_ttl_seconds,
1673                tolerance_mib,
1674            );
1675            if output.json_output {
1676                if output.pretty {
1677                    println!("{}", serde_json::to_string_pretty(&report)?);
1678                } else {
1679                    println!("{}", serde_json::to_string(&report)?);
1680                }
1681            } else {
1682                println!(
1683                    "swap: {} -> {} | status: {:?}",
1684                    report.from_profile_id, report.to_profile_id, report.swap_status
1685                );
1686                println!(
1687                    "unload cleanup: {:?} ({})",
1688                    report.unload.cleanup.status, report.unload.cleanup.reason
1689                );
1690                println!(
1691                    "target resolution: {:?} -> {} (selectable: {})",
1692                    report.target_resolution.source,
1693                    report.target_resolution.profile.id,
1694                    report.target_resolution.selectable
1695                );
1696                for note in &report.notes {
1697                    println!("note: {note}");
1698                }
1699            }
1700            if strict {
1701                match report.swap_status {
1702                    tsift_local_model::SwapStatus::UnloadNotProven => {
1703                        bail!(
1704                            "swap blocked: source unload cleanup was not proven ({})",
1705                            report.unload.cleanup.reason
1706                        );
1707                    }
1708                    tsift_local_model::SwapStatus::UnloadProvenTargetUnselectable => {
1709                        bail!(
1710                            "swap blocked: target {} is not selectable on the post-unload probe",
1711                            report.to_profile_id
1712                        );
1713                    }
1714                    _ => {}
1715                }
1716            }
1717            Ok(())
1718        }
1719    }
1720}
1721
1722/// Resolve a profile's Ollama model tag and POST a `keep_alive:0` unload.
1723///
1724/// Used by the reference-counted lease paths (`release --unload-on-last-release`,
1725/// `reap --unload-empty`) once a profile's live holder count reaches zero. An
1726/// unknown profile or unreachable provider degrades to a non-fatal
1727/// `UnloadActionResult` so reaping never fails on unload.
1728fn unload_profile_model(
1729    profile_id: &str,
1730    host: Option<&str>,
1731) -> tsift_local_model::UnloadActionResult {
1732    let endpoint = tsift_local_model::resolve_provider_endpoint(
1733        &tsift_local_model::UnloadStrategy::OllamaKeepAliveZero,
1734        host,
1735    );
1736    match tsift_local_model::profile_by_id(profile_id) {
1737        Some(profile) => tsift_local_model::unload_model_at(&endpoint, profile.model_ref),
1738        None => tsift_local_model::unload_model_at(&endpoint, profile_id),
1739    }
1740}
1741
1742fn cmd_local_model_lease(command: LeaseCommand, output: OutputFormat) -> Result<()> {
1743    use tsift_local_model::{
1744        acquire_lease, current_unix_seconds, format_lease_show_human, lease_mode_for_profile,
1745        profile_by_id, reap_leases, release_lease, renew_lease, resolve_lease_file, show_registry,
1746    };
1747    let now = current_unix_seconds();
1748    match command {
1749        LeaseCommand::Acquire {
1750            profile,
1751            holder_pid,
1752            holder_command,
1753            idle_ttl_seconds,
1754            vram_baseline_mib,
1755            no_probe,
1756            lease_file,
1757            strict,
1758            ..
1759        } => {
1760            let profile_lookup = profile_by_id(&profile)
1761                .with_context(|| format!("unknown local model profile {profile:?}"))?;
1762            // CpuOrHash profiles bypass the registry; confirm that upfront.
1763            let _ = lease_mode_for_profile(&profile_lookup);
1764            let pid = holder_pid.unwrap_or_else(std::process::id);
1765            let baseline = match vram_baseline_mib {
1766                Some(value) => value,
1767                None => {
1768                    if no_probe {
1769                        0
1770                    } else {
1771                        let probe = tsift_local_model::probe_nvidia_smi();
1772                        probe.used_vram_mib.unwrap_or(0)
1773                    }
1774                }
1775            };
1776            let path = resolve_lease_file(lease_file.as_deref());
1777            let acquisition = acquire_lease(
1778                &profile,
1779                pid,
1780                &holder_command,
1781                baseline,
1782                idle_ttl_seconds,
1783                now,
1784                &path,
1785            )?;
1786            if output.json_output {
1787                if output.pretty {
1788                    println!("{}", serde_json::to_string_pretty(&acquisition)?);
1789                } else {
1790                    println!("{}", serde_json::to_string(&acquisition)?);
1791                }
1792            } else {
1793                println!(
1794                    "lease {} for {} (pid={}): {:?}",
1795                    match acquisition.status {
1796                        tsift_local_model::GpuLeaseAcquisitionStatus::Acquired => "acquired",
1797                        tsift_local_model::GpuLeaseAcquisitionStatus::Refreshed => "refreshed",
1798                        tsift_local_model::GpuLeaseAcquisitionStatus::ReclaimedStale => {
1799                            "reclaimed-stale"
1800                        }
1801                        tsift_local_model::GpuLeaseAcquisitionStatus::CpuOrHashBypass => {
1802                            "bypass-cpu-or-hash"
1803                        }
1804                        tsift_local_model::GpuLeaseAcquisitionStatus::Conflict => "conflicted",
1805                    },
1806                    acquisition.profile_id,
1807                    acquisition.holder_pid,
1808                    acquisition.status
1809                );
1810                if let Some(conflict) = &acquisition.conflict {
1811                    println!(
1812                        "held by pid={} cmd={} acquired {}s ago",
1813                        conflict.holder_pid,
1814                        conflict.holder_command,
1815                        now.saturating_sub(conflict.acquired_at_unix_seconds)
1816                    );
1817                }
1818                println!("registry: {}", path.display());
1819            }
1820            if strict
1821                && acquisition.status == tsift_local_model::GpuLeaseAcquisitionStatus::Conflict
1822            {
1823                bail!(
1824                    "gpu lease for {profile:?} is held by pid={}",
1825                    acquisition
1826                        .conflict
1827                        .map(|conflict| conflict.holder_pid.to_string())
1828                        .unwrap_or_else(|| "unknown".to_string())
1829                );
1830            }
1831            Ok(())
1832        }
1833        LeaseCommand::Release {
1834            profile,
1835            holder_pid,
1836            lease_file,
1837            unload_on_last_release,
1838            host,
1839            ..
1840        } => {
1841            let pid = holder_pid.unwrap_or_else(std::process::id);
1842            let path = resolve_lease_file(lease_file.as_deref());
1843            let release = release_lease(&profile, pid, now, &path)?;
1844            // Reference-counted unload: when this release drops the live holder
1845            // count to zero, the model is no longer referenced by any session.
1846            let unloaded = if unload_on_last_release
1847                && release.outcome == tsift_local_model::GpuLeaseReleaseOutcome::Released
1848                && release.remaining_holders == 0
1849            {
1850                Some(unload_profile_model(&profile, host.as_deref()))
1851            } else {
1852                None
1853            };
1854            if output.json_output {
1855                let payload = serde_json::json!({
1856                    "release": release,
1857                    "unloaded": unloaded,
1858                });
1859                if output.pretty {
1860                    println!("{}", serde_json::to_string_pretty(&payload)?);
1861                } else {
1862                    println!("{}", serde_json::to_string(&payload)?);
1863                }
1864            } else {
1865                println!(
1866                    "release {} for {} (pid={}): {:?} (remaining holders: {})",
1867                    match release.outcome {
1868                        tsift_local_model::GpuLeaseReleaseOutcome::Released => "ok",
1869                        tsift_local_model::GpuLeaseReleaseOutcome::NotHeld => "not-held",
1870                        tsift_local_model::GpuLeaseReleaseOutcome::ProfileAbsent => "absent",
1871                    },
1872                    release.profile_id,
1873                    release.holder_pid,
1874                    release.outcome,
1875                    release.remaining_holders
1876                );
1877                if let Some(result) = &unloaded {
1878                    println!("unloaded {} (last reference released): {}", profile, result.outcome);
1879                }
1880                println!("registry: {}", path.display());
1881            }
1882            Ok(())
1883        }
1884        LeaseCommand::Renew {
1885            profile,
1886            holder_pid,
1887            lease_file,
1888            ..
1889        } => {
1890            let pid = holder_pid.unwrap_or_else(std::process::id);
1891            let path = resolve_lease_file(lease_file.as_deref());
1892            let renew = renew_lease(&profile, pid, now, &path)?;
1893            if output.json_output {
1894                if output.pretty {
1895                    println!("{}", serde_json::to_string_pretty(&renew)?);
1896                } else {
1897                    println!("{}", serde_json::to_string(&renew)?);
1898                }
1899            } else {
1900                println!(
1901                    "renew {} (pid={}): {:?}",
1902                    renew.profile_id, renew.holder_pid, renew.outcome
1903                );
1904                println!("registry: {}", path.display());
1905            }
1906            Ok(())
1907        }
1908        LeaseCommand::Reap {
1909            lease_file,
1910            unload_empty,
1911            host,
1912            ..
1913        } => {
1914            let path = resolve_lease_file(lease_file.as_deref());
1915            let reap = reap_leases(now, &path)?;
1916            // Reference-counted unload for every profile whose last reference
1917            // was reclaimed (a crashed session left a dead-pid holder).
1918            let unloaded: Vec<_> = if unload_empty {
1919                reap.emptied_profiles
1920                    .iter()
1921                    .filter_map(|profile_id| {
1922                        profile_by_id(profile_id).map(|profile| {
1923                            serde_json::json!({
1924                                "profile": profile_id,
1925                                "outcome": unload_profile_model(profile_id, host.as_deref()).outcome,
1926                                "model": profile.model_ref,
1927                            })
1928                        })
1929                    })
1930                    .collect()
1931            } else {
1932                Vec::new()
1933            };
1934            if output.json_output {
1935                let payload = serde_json::json!({
1936                    "reap": reap,
1937                    "unloaded": unloaded,
1938                });
1939                if output.pretty {
1940                    println!("{}", serde_json::to_string_pretty(&payload)?);
1941                } else {
1942                    println!("{}", serde_json::to_string(&payload)?);
1943                }
1944            } else {
1945                println!(
1946                    "reaped {} stale holder(s); {} profile(s) dropped to zero references",
1947                    reap.reclaimed.len(),
1948                    reap.emptied_profiles.len()
1949                );
1950                for profile_id in &reap.emptied_profiles {
1951                    println!("  emptied: {profile_id}");
1952                }
1953                if !unloaded.is_empty() {
1954                    println!("unloaded {} unreferenced model(s)", unloaded.len());
1955                }
1956                println!("registry: {}", path.display());
1957            }
1958            Ok(())
1959        }
1960        LeaseCommand::Show {
1961            lease_file,
1962            include_stale,
1963            ..
1964        } => {
1965            let path = resolve_lease_file(lease_file.as_deref());
1966            let registry = show_registry(&path, now, include_stale)?;
1967            if output.json_output {
1968                if output.pretty {
1969                    println!("{}", serde_json::to_string_pretty(&registry)?);
1970                } else {
1971                    println!("{}", serde_json::to_string(&registry)?);
1972                }
1973            } else {
1974                print!("{}", format_lease_show_human(&registry, now));
1975            }
1976            println!("registry: {}", path.display());
1977            Ok(())
1978        }
1979    }
1980}
1981
1982fn lifecycle_probe(
1983    no_probe: bool,
1984    synthetic_used_mib: Option<u64>,
1985    skipped_reason: &str,
1986) -> tsift_local_model::GpuProbe {
1987    if let Some(used_mib) = synthetic_used_mib {
1988        return tsift_local_model::GpuProbe::synthetic_vram(used_mib);
1989    }
1990    if no_probe {
1991        return tsift_local_model::GpuProbe::unavailable(skipped_reason);
1992    }
1993    tsift_local_model::probe_nvidia_smi()
1994}
1995
1996/// Classify a task description into a model tier.
1997/// Returns (tier_name, model_id).
1998pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1999    let lower = task.to_lowercase();
2000    // Architecture/design signals → opus
2001    for signal in &[
2002        "architect",
2003        "architecture",
2004        "design",
2005        "plan",
2006        "strateg",
2007        "analy",
2008        "review",
2009        "evaluate",
2010        "assess",
2011    ] {
2012        if lower.contains(signal) {
2013            return ("opus", "claude-opus-4-6");
2014        }
2015    }
2016    // Edit/write signals → sonnet
2017    for signal in &[
2018        "edit",
2019        "write",
2020        "fix",
2021        "change",
2022        "update",
2023        "create",
2024        "add ",
2025        "remove",
2026        "delete",
2027        "modify",
2028        "refactor",
2029        "implement",
2030        "build",
2031    ] {
2032        if lower.contains(signal) {
2033            return ("sonnet", "claude-sonnet-4-6");
2034        }
2035    }
2036    // Default: search/lookup → haiku
2037    ("haiku", "claude-haiku-4-5-20251001")
2038}
2039
2040#[cfg(test)]
2041fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
2042    to_json_schema(val, pretty, terse, false, false)
2043}
2044
2045/// Add top-level `tagpath_index_stale: true` + `tagpath_stale_reason: <reason>`
2046/// fields to a JSON response when the tagpath adapter reported any helper
2047/// going stale. JSON consumers (`tsift --envelope` / `--json` callers) can
2048/// then act on the same condition the stderr `tagpath_index_stale: …` log
2049/// already surfaces without parsing logs. No-op when `stale=false` or when
2050/// `value` is not a JSON object.
2051pub(crate) fn inject_tagpath_stale_into_json(
2052    value: &mut serde_json::Value,
2053    stale: bool,
2054    reason: Option<&str>,
2055) {
2056    if !stale {
2057        return;
2058    }
2059    if let Some(obj) = value.as_object_mut() {
2060        obj.insert(
2061            "tagpath_index_stale".to_string(),
2062            serde_json::Value::Bool(true),
2063        );
2064        if let Some(reason) = reason {
2065            obj.insert(
2066                "tagpath_stale_reason".to_string(),
2067                serde_json::Value::String(reason.to_string()),
2068            );
2069        }
2070    }
2071}
2072
2073pub(crate) fn to_json_schema<T: serde::Serialize>(
2074    val: &T,
2075    pretty: bool,
2076    terse: bool,
2077    ultra_terse: bool,
2078    schema: bool,
2079) -> anyhow::Result<String> {
2080    if terse || schema {
2081        let value = serde_json::to_value(val)?;
2082        let mut transformed = if terse { terse_transform(value) } else { value };
2083        if ultra_terse {
2084            transformed = ultra_terse_transform(transformed);
2085            transformed = edge_index_transform(transformed);
2086        }
2087        if schema {
2088            transformed = schema_transform(transformed);
2089        }
2090        if terse {
2091            let terse_schema = terse_schema_for(&transformed);
2092            let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
2093            if pretty {
2094                Ok(serde_json::to_string_pretty(&wrapped)?)
2095            } else {
2096                Ok(serde_json::to_string(&wrapped)?)
2097            }
2098        } else if pretty {
2099            Ok(serde_json::to_string_pretty(&transformed)?)
2100        } else {
2101            Ok(serde_json::to_string(&transformed)?)
2102        }
2103    } else if pretty {
2104        Ok(serde_json::to_string_pretty(val)?)
2105    } else {
2106        Ok(serde_json::to_string(val)?)
2107    }
2108}
2109
2110pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
2111    ToolEnvelopeMetric {
2112        label: label.to_string(),
2113        value: value.to_string(),
2114    }
2115}
2116
2117pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
2118    let mut seen = HashSet::new();
2119    let mut deduped = Vec::new();
2120    for value in values {
2121        if seen.insert(value.clone()) {
2122            deduped.push(value);
2123        }
2124    }
2125    deduped
2126}
2127
2128pub(crate) fn print_json_or_envelope<T: Serialize>(
2129    report: &T,
2130    format: &OutputFormat,
2131    tool: &str,
2132    view: &str,
2133    summary: ToolEnvelopeSummary,
2134    truncated: bool,
2135    follow_up: Vec<String>,
2136) -> Result<()> {
2137    if format.envelope {
2138        let schema = format.schema || tool == "source-read";
2139        let envelope = ToolEnvelope {
2140            tool,
2141            view,
2142            summary,
2143            truncated,
2144            follow_up: dedupe_preserve_order(follow_up),
2145            report,
2146        };
2147        println!(
2148            "{}",
2149            to_json_schema(
2150                &envelope,
2151                format.pretty,
2152                format.terse,
2153                format.ultra_terse,
2154                schema
2155            )?
2156        );
2157    } else {
2158        println!(
2159            "{}",
2160            to_json_schema(
2161                report,
2162                format.pretty,
2163                format.terse,
2164                format.ultra_terse,
2165                format.schema
2166            )?
2167        );
2168    }
2169    Ok(())
2170}
2171
2172pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
2173    bytes.div_ceil(4)
2174}
2175
2176fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
2177    match command {
2178        cli::TokenGateCommand::Sample {
2179            surface,
2180            path,
2181            scope,
2182            target,
2183            depth,
2184            sample_index,
2185            json: _,
2186        } => cmd_token_gate_sample(
2187            &surface,
2188            &path,
2189            scope.as_deref(),
2190            target.as_deref(),
2191            depth,
2192            sample_index,
2193        ),
2194        cli::TokenGateCommand::Evaluate {
2195            history,
2196            allowed_regression_percent,
2197            json: _,
2198        } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
2199    }
2200}
2201
2202fn cmd_token_gate_sample(
2203    surface: &str,
2204    path: &Path,
2205    scope: Option<&str>,
2206    target: Option<&str>,
2207    depth: usize,
2208    sample_index: usize,
2209) -> Result<()> {
2210    if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
2211        bail!(
2212            "unknown surface `{}`; expected one of: {}",
2213            surface,
2214            token_gate::TOKEN_GATE_SURFACES.join(", ")
2215        );
2216    }
2217
2218    let path_str = path.to_string_lossy().to_string();
2219    let tsift_bin = std::env::current_exe()?;
2220
2221    let args: Vec<String> = match surface {
2222        "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
2223        "session_review_next_context" => vec![
2224            "session-review".to_string(),
2225            "--json".to_string(),
2226            "--next-context".to_string(),
2227            path_str,
2228        ],
2229        "graph_db_evidence" => {
2230            let tgt = target.unwrap_or("default").to_string();
2231            vec![
2232                "graph-db".to_string(),
2233                "--json".to_string(),
2234                "--path".to_string(),
2235                path_str,
2236                "evidence".to_string(),
2237                tgt,
2238                "--depth".to_string(),
2239                depth.to_string(),
2240            ]
2241        }
2242        "conflict_matrix" => {
2243            let tgt = target.unwrap_or("default").to_string();
2244            let mut a = vec![
2245                "conflict-matrix".to_string(),
2246                "--json".to_string(),
2247                "--path".to_string(),
2248                path_str,
2249                "--depth".to_string(),
2250                depth.to_string(),
2251            ];
2252            if let Some(s) = scope {
2253                a.push("--scope".to_string());
2254                a.push(s.to_string());
2255            }
2256            a.push(tgt);
2257            a
2258        }
2259        "dispatch_trace" => {
2260            let tgt = target.unwrap_or("default").to_string();
2261            vec![
2262                "dispatch-trace".to_string(),
2263                "--json".to_string(),
2264                "--path".to_string(),
2265                path_str,
2266                tgt,
2267            ]
2268        }
2269        _ => bail!("unhandled surface: {}", surface),
2270    };
2271
2272    let start = Instant::now();
2273    let child = Command::new(&tsift_bin)
2274        .args(&args)
2275        .stdout(Stdio::piped())
2276        .stderr(Stdio::piped())
2277        .env("TSIFT_QUIET", "1")
2278        .spawn();
2279    let output = match child {
2280        Ok(c) => c.wait_with_output()?,
2281        Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
2282    };
2283    let runtime_micros = start.elapsed().as_micros() as f64;
2284
2285    let stdout = String::from_utf8_lossy(&output.stdout);
2286    let envelope_bytes = stdout.trim().len() as f64;
2287    let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
2288
2289    let cache_hit_rate_percent = 0.0;
2290    let raw_read_avoidance = 0.0;
2291    let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
2292
2293    let timestamp = iso_timestamp_now();
2294    let id = format!(
2295        "{surface}-baseline-{}-sample-{sample_index}",
2296        &timestamp[..10]
2297    );
2298    let label = format!(
2299        "token-gate baseline {surface} sample {sample_index} for {}",
2300        path.display()
2301    );
2302
2303    let mut metrics = BTreeMap::new();
2304    metrics.insert("prompt_tokens".to_string(), prompt_tokens);
2305    metrics.insert("envelope_bytes".to_string(), envelope_bytes);
2306    metrics.insert("runtime_micros".to_string(), runtime_micros);
2307    metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
2308    metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
2309    metrics.insert("useful_hit_density".to_string(), useful_hit_density);
2310
2311    let sample = token_gate::TokenGateSample {
2312        label,
2313        id,
2314        timestamp: Some(timestamp),
2315        surface: surface.to_string(),
2316        metrics,
2317    };
2318
2319    println!("{}", serde_json::to_string_pretty(&sample)?);
2320    Ok(())
2321}
2322
2323fn cmd_token_gate_evaluate(
2324    history_path: Option<&Path>,
2325    allowed_regression_percent: f64,
2326    format: &OutputFormat,
2327) -> Result<()> {
2328    let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
2329        let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2330        p.push("../../fixtures/token-gate-history.json");
2331        p
2332    });
2333
2334    let raw = std::fs::read_to_string(&history_path).with_context(|| {
2335        format!(
2336            "failed to read token gate history: {}",
2337            history_path.display()
2338        )
2339    })?;
2340    let samples = token_gate::parse_token_history(&raw)?;
2341    let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
2342
2343    if format.json_output {
2344        println!(
2345            "{}",
2346            to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
2347        );
2348    } else {
2349        println!("Token Gate Report");
2350        println!("  min_samples: {}", report.min_samples);
2351        println!(
2352            "  allowed_regression: {:.1}%",
2353            report.allowed_regression_percent
2354        );
2355        println!("  decision: {:?}", report.decision);
2356        for eval in &report.surface_evaluations {
2357            println!(
2358                "  {} ({} samples): {:?}",
2359                eval.display_name, eval.sample_count, eval.verdict
2360            );
2361            for me in &eval.metric_evaluations {
2362                println!("    {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
2363            }
2364        }
2365        for d in &report.diagnostics {
2366            println!("  ! {}", d);
2367        }
2368    }
2369    Ok(())
2370}
2371
2372fn iso_timestamp_now() -> String {
2373    let dur = SystemTime::now()
2374        .duration_since(UNIX_EPOCH)
2375        .unwrap_or_default();
2376    let total_secs = dur.as_secs();
2377    let days_since_epoch = total_secs / 86400;
2378    let (year, month, day) = days_to_ymd(days_since_epoch);
2379    let time_of_day = total_secs % 86400;
2380    let hour = (time_of_day / 3600) as u8;
2381    let minute = ((time_of_day % 3600) / 60) as u8;
2382    let second = (time_of_day % 60) as u8;
2383    format!(
2384        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
2385        year, month, day, hour, minute, second
2386    )
2387}
2388
2389fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
2390    let mut year = 1970u64;
2391    loop {
2392        let days_in_year = if is_leap(year) { 366 } else { 365 };
2393        if days < days_in_year {
2394            break;
2395        }
2396        days -= days_in_year;
2397        year += 1;
2398    }
2399    let leap = is_leap(year);
2400    let month_days: [u8; 12] = if leap {
2401        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2402    } else {
2403        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2404    };
2405    let mut month: u8 = 1;
2406    for &md in &month_days {
2407        if days < md as u64 {
2408            break;
2409        }
2410        days -= md as u64;
2411        month += 1;
2412    }
2413    let day = days as u8 + 1;
2414    (year, month, day)
2415}
2416
2417fn is_leap(year: u64) -> bool {
2418    year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
2419}
2420
2421fn persist_transcript_artifact(
2422    root: &Path,
2423    prefix: &str,
2424    suffix: &str,
2425    key: &str,
2426    body: &str,
2427    expand: String,
2428) -> Result<TranscriptArtifactRef> {
2429    let handle = stable_handle(prefix, key);
2430    let artifacts_dir = root.join(".tsift/artifacts");
2431    fs::create_dir_all(&artifacts_dir).with_context(|| {
2432        format!(
2433            "creating transcript artifacts dir: {}",
2434            artifacts_dir.display()
2435        )
2436    })?;
2437    let file_name = format!("{handle}.{suffix}");
2438    let artifact_path = artifacts_dir.join(file_name);
2439    fs::write(&artifact_path, body)
2440        .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
2441    let rel_path = relativize_pathbuf(&artifact_path, root);
2442    Ok(TranscriptArtifactRef {
2443        handle,
2444        path: rel_path.display().to_string(),
2445        bytes: body.len(),
2446        lines: body.lines().count(),
2447        expand,
2448    })
2449}
2450
2451fn terse_key(key: &str) -> &str {
2452    match key {
2453        "name" => "n",
2454        "kind" => "k",
2455        "file" => "f",
2456        "line" => "l",
2457        "path" => "p",
2458        "from" => "fr",
2459        "type" => "ty",
2460        "text" => "tx",
2461        "new" => "nw",
2462        "run" => "r",
2463        "use" => "u",
2464        "score" => "sc",
2465        "language" => "la",
2466        "status" => "st",
2467        "state" => "stt",
2468        "error" => "err",
2469        "errors" => "ers",
2470        "hops" => "hp",
2471        "tags" => "tg",
2472        "model" => "ml",
2473        "skill" => "sk",
2474        "count" => "ct",
2475        "total" => "tot",
2476        "column" => "col",
2477        "description" => "dsc",
2478        "end_line" => "el",
2479        "signature" => "sig",
2480        "parent_module" => "pm",
2481        "visibility" => "vis",
2482        "match_type" => "mt",
2483        "caller_file" => "cf",
2484        "caller_name" => "cn",
2485        "caller_line" => "cl",
2486        "callee_name" => "en",
2487        "call_site_line" => "csl",
2488        "members" => "m",
2489        "refs" => "refs",
2490        "role" => "rl",
2491        "peer" => "pr",
2492        "modularity" => "q",
2493        "modularity_contribution" => "mc",
2494        "iterations" => "it",
2495        "node_count" => "nc",
2496        "edge_count" => "ec",
2497        "community_count" => "cc",
2498        "communities" => "cms",
2499        "community" => "cm",
2500        "community_diagnostics" => "cd",
2501        "cache_hit" => "cah",
2502        "tagpath_state" => "tps",
2503        "tagpath_stale_reason" => "tsr",
2504        "annotated_community_count" => "acc",
2505        "annotated_member_count" => "amc",
2506        "ambiguous_member_count" => "ambc",
2507        "ambiguous_members" => "amb",
2508        "candidate_count" => "cand",
2509        "tagpath_candidate_count" => "tcand",
2510        "evidence" => "ev",
2511        "chosen_file" => "chf",
2512        "symbol" => "s",
2513        "symbols" => "sy",
2514        "definitions" => "df",
2515        "callers" => "crs",
2516        "callees" => "ces",
2517        "total_tracked" => "tt",
2518        "modified" => "md",
2519        "deleted" => "dl",
2520        "unchanged" => "uc",
2521        "changes" => "ch",
2522        "prune_stats" => "ps",
2523        "hits" => "h",
2524        "rank" => "rk",
2525        "snippet" => "sn",
2526        "confidence" => "co",
2527        "index" => "ix",
2528        "summaries" => "sms",
2529        "recommendations" => "rec",
2530        "total_files" => "tf",
2531        "stale_files" => "sf",
2532        "last_indexed_secs_ago" => "age",
2533        "cached_files" => "caf",
2534        "total_indexed_files" => "tif",
2535        "coverage_pct" => "cov",
2536        "symbol_name" => "syn",
2537        "file_path" => "fp",
2538        "content_hash" => "hsh",
2539        "summary" => "sum",
2540        "tool" => "tl",
2541        "view" => "vw",
2542        "truncated" => "tr",
2543        "follow_up" => "fu",
2544        "report" => "rp",
2545        "metrics" => "ms",
2546        "label" => "lb",
2547        "value" => "v",
2548        "command" => "cmd",
2549        "exit_code" => "xc",
2550        "success" => "ok",
2551        "artifact" => "art",
2552        "digest" => "dg",
2553        "bytes" => "bt",
2554        "lines" => "lns",
2555        "expand" => "xp",
2556        "entities" => "ent",
2557        "relationships" => "rel",
2558        "concept_labels" => "cls",
2559        "extracted_at" => "at",
2560        "tokens_input" => "ti",
2561        "tokens_output" => "tout",
2562        "total_summaries" => "ts",
2563        "stale_count" => "stc",
2564        "total_tokens_input" => "tti",
2565        "total_tokens_output" => "tto",
2566        "estimated_tokens_saved" => "ets",
2567        "files_processed" => "fps",
2568        "symbols_extracted" => "se",
2569        "skills_dir" => "sd",
2570        "healthy" => "ok",
2571        "broken" => "brk",
2572        "skills" => "sks",
2573        "manifest_diffs" => "mdf",
2574        "similar_pairs" => "sim",
2575        "usage" => "usg",
2576        "cleanup" => "cln",
2577        "has_skill_md" => "hsm",
2578        "is_symlink" => "isl",
2579        "issues" => "iss",
2580        "invocation_count" => "inv",
2581        "reasons" => "rsn",
2582        "token_estimate" => "te",
2583        "skill_a" => "sa",
2584        "skill_b" => "sb",
2585        "desc_a" => "da",
2586        "desc_b" => "db",
2587        "annotations" => "ann",
2588        "entity" => "ety",
2589        "suggestion" => "sug",
2590        "columns" => "cols",
2591        "row_count" => "rc",
2592        "notnull" => "nn",
2593        "default_value" => "dv",
2594        "replace_all" => "ra",
2595        other => other,
2596    }
2597}
2598
2599fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2600    match val {
2601        serde_json::Value::Object(map) => {
2602            let mut new_map = serde_json::Map::new();
2603            for (k, v) in map {
2604                new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2605            }
2606            serde_json::Value::Object(new_map)
2607        }
2608        serde_json::Value::Array(arr) => {
2609            serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2610        }
2611        other => other,
2612    }
2613}
2614
2615fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2616    match val {
2617        serde_json::Value::Object(mut map) => {
2618            let is_graph_node =
2619                map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2620            let is_graph_edge =
2621                map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2622            if is_graph_node || is_graph_edge {
2623                map.remove("properties");
2624                map.remove("provenance");
2625                map.remove("freshness");
2626            }
2627            if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2628                *s = abbreviate_edge_kind(s).to_string();
2629            }
2630            let is_coverage = map.contains_key("mode")
2631                && (map.contains_key("total_sector_count")
2632                    || map.contains_key("dirty_sector_count"));
2633            if is_coverage {
2634                map.remove("active_rebuild");
2635                map.remove("completed_dirty_sector_count");
2636                map.remove("mounted_sector_count");
2637                map.remove("rebuilding_sector_count");
2638                map.remove("resumed_sector_count");
2639                map.remove("reused_sector_count");
2640            }
2641            if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2642                *s = truncate_for_ultra_terse(s, 80);
2643            }
2644            if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2645                *s = truncate_for_ultra_terse(s, 80);
2646            }
2647            let new_map: serde_json::Map<String, serde_json::Value> = map
2648                .into_iter()
2649                .map(|(k, v)| (k, ultra_terse_transform(v)))
2650                .collect();
2651            serde_json::Value::Object(new_map)
2652        }
2653        serde_json::Value::Array(arr) => {
2654            serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2655        }
2656        other => other,
2657    }
2658}
2659
2660fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2661    match val {
2662        serde_json::Value::Object(mut map) => {
2663            let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2664                nodes.as_array().map(|arr| {
2665                    arr.iter()
2666                        .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2667                        .collect()
2668                })
2669            });
2670            if let Some(ref ids) = node_ids {
2671                let id_map: std::collections::HashMap<&str, usize> = ids
2672                    .iter()
2673                    .enumerate()
2674                    .map(|(i, id)| (id.as_str(), i))
2675                    .collect();
2676                if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2677                    for edge in edges.iter_mut() {
2678                        if let serde_json::Value::Object(edge_map) = edge {
2679                            if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2680                            {
2681                                if let Some(&idx) = id_map.get(fid.as_str()) {
2682                                    edge_map.insert(
2683                                        "from".to_string(),
2684                                        serde_json::Value::Number(idx.into()),
2685                                    );
2686                                } else {
2687                                    edge_map.insert(
2688                                        "from_id".to_string(),
2689                                        serde_json::Value::String(fid),
2690                                    );
2691                                }
2692                            }
2693                            if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2694                                if let Some(&idx) = id_map.get(tid.as_str()) {
2695                                    edge_map.insert(
2696                                        "to".to_string(),
2697                                        serde_json::Value::Number(idx.into()),
2698                                    );
2699                                } else {
2700                                    edge_map.insert(
2701                                        "to_id".to_string(),
2702                                        serde_json::Value::String(tid),
2703                                    );
2704                                }
2705                            }
2706                        }
2707                    }
2708                }
2709            }
2710            let new_map: serde_json::Map<String, serde_json::Value> = map
2711                .into_iter()
2712                .map(|(k, v)| (k, edge_index_transform(v)))
2713                .collect();
2714            serde_json::Value::Object(new_map)
2715        }
2716        serde_json::Value::Array(arr) => {
2717            serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2718        }
2719        other => other,
2720    }
2721}
2722
2723fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2724    if s.len() <= max_len {
2725        s.to_string()
2726    } else {
2727        let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2728        format!("{truncated}...")
2729    }
2730}
2731
2732fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2733    let mut keys = HashSet::new();
2734    collect_terse_keys(val, &mut keys);
2735    let mut schema = serde_json::Map::new();
2736    for (long, short) in TERSE_PAIRS {
2737        if keys.contains(*short) {
2738            schema.insert(
2739                short.to_string(),
2740                serde_json::Value::String(long.to_string()),
2741            );
2742        }
2743    }
2744    serde_json::Value::Object(schema)
2745}
2746
2747fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2748    match val {
2749        serde_json::Value::Object(map) => {
2750            for (k, v) in map {
2751                keys.insert(k.clone());
2752                collect_terse_keys(v, keys);
2753            }
2754        }
2755        serde_json::Value::Array(arr) => {
2756            for v in arr {
2757                collect_terse_keys(v, keys);
2758            }
2759        }
2760        _ => {}
2761    }
2762}
2763
2764fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2765    match val {
2766        serde_json::Value::Array(arr) if arr.len() >= 2 => {
2767            if let Some(cols) = homogeneous_keys(&arr) {
2768                let rows: Vec<serde_json::Value> = arr
2769                    .into_iter()
2770                    .map(|item| {
2771                        if let serde_json::Value::Object(map) = item {
2772                            let vals: Vec<serde_json::Value> = cols
2773                                .iter()
2774                                .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2775                                .collect();
2776                            serde_json::Value::Array(vals)
2777                        } else {
2778                            item
2779                        }
2780                    })
2781                    .collect();
2782                let col_vals: Vec<serde_json::Value> =
2783                    cols.into_iter().map(serde_json::Value::String).collect();
2784                serde_json::json!({"_c": col_vals, "_r": rows})
2785            } else {
2786                serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2787            }
2788        }
2789        serde_json::Value::Array(arr) => {
2790            serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2791        }
2792        serde_json::Value::Object(map) => {
2793            let new_map: serde_json::Map<String, serde_json::Value> = map
2794                .into_iter()
2795                .map(|(k, v)| (k, schema_transform(v)))
2796                .collect();
2797            serde_json::Value::Object(new_map)
2798        }
2799        other => other,
2800    }
2801}
2802
2803fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2804    let first = arr.first()?.as_object()?;
2805    let keys: Vec<String> = first.keys().cloned().collect();
2806    for item in &arr[1..] {
2807        let obj = item.as_object()?;
2808        if obj.len() != keys.len() {
2809            return None;
2810        }
2811        for k in &keys {
2812            if !obj.contains_key(k) {
2813                return None;
2814            }
2815        }
2816    }
2817    Some(keys)
2818}
2819
2820const TERSE_PAIRS: &[(&str, &str)] = &[
2821    ("name", "n"),
2822    ("kind", "k"),
2823    ("file", "f"),
2824    ("line", "l"),
2825    ("path", "p"),
2826    ("from", "fr"),
2827    ("type", "ty"),
2828    ("text", "tx"),
2829    ("new", "nw"),
2830    ("run", "r"),
2831    ("use", "u"),
2832    ("score", "sc"),
2833    ("language", "la"),
2834    ("status", "st"),
2835    ("state", "stt"),
2836    ("error", "err"),
2837    ("errors", "ers"),
2838    ("hops", "hp"),
2839    ("tags", "tg"),
2840    ("model", "ml"),
2841    ("skill", "sk"),
2842    ("count", "ct"),
2843    ("total", "tot"),
2844    ("column", "col"),
2845    ("description", "dsc"),
2846    ("end_line", "el"),
2847    ("signature", "sig"),
2848    ("parent_module", "pm"),
2849    ("visibility", "vis"),
2850    ("match_type", "mt"),
2851    ("caller_file", "cf"),
2852    ("caller_name", "cn"),
2853    ("caller_line", "cl"),
2854    ("callee_name", "en"),
2855    ("call_site_line", "csl"),
2856    ("members", "m"),
2857    ("refs", "refs"),
2858    ("role", "rl"),
2859    ("peer", "pr"),
2860    ("modularity", "q"),
2861    ("modularity_contribution", "mc"),
2862    ("iterations", "it"),
2863    ("node_count", "nc"),
2864    ("edge_count", "ec"),
2865    ("community_count", "cc"),
2866    ("communities", "cms"),
2867    ("community", "cm"),
2868    ("community_diagnostics", "cd"),
2869    ("cache_hit", "cah"),
2870    ("tagpath_state", "tps"),
2871    ("tagpath_stale_reason", "tsr"),
2872    ("annotated_community_count", "acc"),
2873    ("annotated_member_count", "amc"),
2874    ("ambiguous_member_count", "ambc"),
2875    ("ambiguous_members", "amb"),
2876    ("candidate_count", "cand"),
2877    ("tagpath_candidate_count", "tcand"),
2878    ("evidence", "ev"),
2879    ("chosen_file", "chf"),
2880    ("symbol", "s"),
2881    ("symbols", "sy"),
2882    ("definitions", "df"),
2883    ("callers", "crs"),
2884    ("callees", "ces"),
2885    ("total_tracked", "tt"),
2886    ("modified", "md"),
2887    ("deleted", "dl"),
2888    ("unchanged", "uc"),
2889    ("changes", "ch"),
2890    ("prune_stats", "ps"),
2891    ("hits", "h"),
2892    ("rank", "rk"),
2893    ("snippet", "sn"),
2894    ("confidence", "co"),
2895    ("index", "ix"),
2896    ("summaries", "sms"),
2897    ("recommendations", "rec"),
2898    ("total_files", "tf"),
2899    ("stale_files", "sf"),
2900    ("last_indexed_secs_ago", "age"),
2901    ("cached_files", "caf"),
2902    ("total_indexed_files", "tif"),
2903    ("coverage_pct", "cov"),
2904    ("symbol_name", "syn"),
2905    ("file_path", "fp"),
2906    ("content_hash", "hsh"),
2907    ("summary", "sum"),
2908    ("tool", "tl"),
2909    ("view", "vw"),
2910    ("truncated", "tr"),
2911    ("follow_up", "fu"),
2912    ("report", "rp"),
2913    ("metrics", "ms"),
2914    ("label", "lb"),
2915    ("value", "v"),
2916    ("command", "cmd"),
2917    ("exit_code", "xc"),
2918    ("success", "ok"),
2919    ("artifact", "art"),
2920    ("digest", "dg"),
2921    ("bytes", "bt"),
2922    ("lines", "lns"),
2923    ("expand", "xp"),
2924    ("entities", "ent"),
2925    ("relationships", "rel"),
2926    ("concept_labels", "cls"),
2927    ("extracted_at", "at"),
2928    ("tokens_input", "ti"),
2929    ("tokens_output", "tout"),
2930    ("total_summaries", "ts"),
2931    ("stale_count", "stc"),
2932    ("total_tokens_input", "tti"),
2933    ("total_tokens_output", "tto"),
2934    ("estimated_tokens_saved", "ets"),
2935    ("files_processed", "fps"),
2936    ("symbols_extracted", "se"),
2937    ("skills_dir", "sd"),
2938    ("healthy", "ok"),
2939    ("broken", "brk"),
2940    ("skills", "sks"),
2941    ("manifest_diffs", "mdf"),
2942    ("similar_pairs", "sim"),
2943    ("usage", "usg"),
2944    ("cleanup", "cln"),
2945    ("has_skill_md", "hsm"),
2946    ("is_symlink", "isl"),
2947    ("issues", "iss"),
2948    ("invocation_count", "inv"),
2949    ("reasons", "rsn"),
2950    ("token_estimate", "te"),
2951    ("skill_a", "sa"),
2952    ("skill_b", "sb"),
2953    ("desc_a", "da"),
2954    ("desc_b", "db"),
2955    ("annotations", "ann"),
2956    ("entity", "ety"),
2957    ("suggestion", "sug"),
2958    ("columns", "cols"),
2959    ("row_count", "rc"),
2960    ("notnull", "nn"),
2961    ("default_value", "dv"),
2962    ("replace_all", "ra"),
2963];
2964
2965pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2966    let root_str = root.to_string_lossy();
2967    let prefix = format!("{}/", root_str.trim_end_matches('/'));
2968    path.strip_prefix(&prefix).unwrap_or(path).to_string()
2969}
2970
2971fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2972    let canonical = path
2973        .canonicalize()
2974        .with_context(|| format!("canonicalizing {}", path.display()))?;
2975    let start = if canonical.is_dir() {
2976        canonical.clone()
2977    } else {
2978        canonical
2979            .parent()
2980            .map(Path::to_path_buf)
2981            .unwrap_or_else(|| canonical.clone())
2982    };
2983
2984    for ancestor in start.ancestors() {
2985        if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2986            return Ok(ancestor.to_path_buf());
2987        }
2988    }
2989
2990    Ok(start)
2991}
2992
2993pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2994    path.strip_prefix(root)
2995        .map(|p| p.to_path_buf())
2996        .unwrap_or_else(|_| path.to_path_buf())
2997}
2998
2999pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
3000    for edge in edges {
3001        edge.caller_file = relativize(&edge.caller_file, root);
3002    }
3003}
3004
3005pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
3006    for sym in symbols {
3007        sym.file = relativize(&sym.file, root);
3008    }
3009}
3010
3011pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
3012    for hit in hits {
3013        hit.file = relativize(&hit.file, root);
3014    }
3015}
3016
3017/// Which endpoint of a `StoredEdge` is the row's primary symbol — caller
3018/// (caller list) or callee (callee list).
3019#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3020pub enum EdgeSide {
3021    Caller,
3022    Callee,
3023}
3024
3025const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
3026
3027pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
3028    let root_str = root.to_string_lossy();
3029    let prefix = format!("{}/", root_str.trim_end_matches('/'));
3030    relativize_json_inner(val, &prefix);
3031}
3032
3033fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
3034    match val {
3035        serde_json::Value::Array(arr) => {
3036            for v in arr {
3037                relativize_json_inner(v, prefix);
3038            }
3039        }
3040        serde_json::Value::Object(map) => {
3041            for (k, v) in map.iter_mut() {
3042                if JSON_PATH_KEYS.contains(&k.as_str())
3043                    && let serde_json::Value::String(s) = v
3044                    && let Some(rest) = s.strip_prefix(prefix)
3045                {
3046                    *s = rest.to_string();
3047                }
3048                relativize_json_inner(v, prefix);
3049            }
3050        }
3051        _ => {}
3052    }
3053}
3054
3055pub(crate) fn format_score(score: f64, compact: bool) -> String {
3056    if compact {
3057        format!("{score:.2}")
3058    } else {
3059        format!("{score:.4}")
3060    }
3061}
3062
3063pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
3064    let trimmed = input.trim();
3065    let count = trimmed.chars().count();
3066    if count <= max_chars {
3067        return trimmed.to_string();
3068    }
3069    let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
3070    format!("{prefix}...")
3071}
3072
3073pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
3074    snippet
3075        .lines()
3076        .find(|line| !line.trim().is_empty())
3077        .map(|line| truncate_for_compact(line, 100))
3078}
3079
3080pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
3081    let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
3082    if names.len() <= limit {
3083        return names.join(", ");
3084    }
3085    format!(
3086        "{} (+{} more)",
3087        names[..limit].join(", "),
3088        names.len() - limit
3089    )
3090}
3091
3092pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
3093    let mut hasher = blake3::Hasher::new();
3094    hasher.update(prefix.as_bytes());
3095    hasher.update(&[0]);
3096    hasher.update(key.as_bytes());
3097    let hex = hasher.finalize().to_hex();
3098    format!("{prefix}-{}", &hex[..10])
3099}
3100
3101#[derive(Clone, Debug, PartialEq, Eq)]
3102struct CanonicalTagFamily {
3103    canonical: String,
3104    tag_alias: String,
3105}
3106
3107fn canonical_family_from_tagpath_family(
3108    family: tagpath_family::TagFamily,
3109) -> Option<CanonicalTagFamily> {
3110    let tag_alias = if family.dimensions.is_empty() {
3111        family.tags.join("/")
3112    } else {
3113        family
3114            .dimensions
3115            .iter()
3116            .filter(|dimension| !dimension.tags.is_empty())
3117            .map(|dimension| dimension.tags.join("."))
3118            .collect::<Vec<_>>()
3119            .join("/")
3120    };
3121
3122    if tag_alias.is_empty() {
3123        None
3124    } else {
3125        Some(CanonicalTagFamily {
3126            canonical: family.canonical,
3127            tag_alias,
3128        })
3129    }
3130}
3131
3132fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
3133    let trimmed = name.trim();
3134    if trimmed.is_empty() {
3135        return None;
3136    }
3137
3138    canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
3139}
3140
3141fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
3142    let canonical = tags
3143        .split(',')
3144        .map(str::trim)
3145        .filter(|tag| !tag.is_empty())
3146        .collect::<Vec<_>>()
3147        .join("_");
3148    if canonical.is_empty() {
3149        None
3150    } else {
3151        canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
3152    }
3153}
3154
3155pub(crate) fn canonical_tag_family_from_symbol(
3156    name: &str,
3157    tags: Option<&str>,
3158) -> Option<CanonicalTagFamily> {
3159    tags.and_then(canonical_tag_family_from_tags)
3160        .or_else(|| canonical_tag_family_from_name(name))
3161}
3162
3163fn tag_alias_from_name(name: &str) -> Option<String> {
3164    canonical_tag_family_from_name(name).map(|family| family.tag_alias)
3165}
3166
3167fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
3168    canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
3169}
3170
3171pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
3172    let query = tag_alias
3173        .split(['/', '.'])
3174        .map(str::trim)
3175        .filter(|part| !part.is_empty())
3176        .collect::<Vec<_>>()
3177        .join(" ");
3178    if query.is_empty() { None } else { Some(query) }
3179}
3180
3181#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3182struct CompactOntologyRefPreview {
3183    handle: String,
3184    tag: String,
3185    path: String,
3186    #[serde(skip_serializing_if = "Option::is_none")]
3187    title: Option<String>,
3188    #[serde(skip_serializing_if = "Option::is_none")]
3189    domain: Option<String>,
3190}
3191
3192#[derive(Clone, Debug)]
3193struct TagOntologyPreviewContext {
3194    project_root: PathBuf,
3195    tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
3196}
3197
3198#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3199struct CompactSymbolRefPreview {
3200    handle: String,
3201    name: String,
3202    #[serde(skip_serializing_if = "Option::is_none")]
3203    tag_alias: Option<String>,
3204    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3205    ontology_refs: Vec<CompactOntologyRefPreview>,
3206}
3207
3208fn build_compact_symbol_ref(
3209    prefix: &str,
3210    key: &str,
3211    name: &str,
3212    tags: Option<&str>,
3213    max_bytes: usize,
3214) -> CompactSymbolRefPreview {
3215    build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
3216}
3217
3218fn build_compact_symbol_ref_with_ontology(
3219    prefix: &str,
3220    key: &str,
3221    name: &str,
3222    tags: Option<&str>,
3223    max_bytes: usize,
3224    ontology: Option<&TagOntologyPreviewContext>,
3225) -> CompactSymbolRefPreview {
3226    let tag_alias = tag_alias_from_tags(name, tags);
3227    let ontology_refs = tag_alias
3228        .as_deref()
3229        .map(|alias| ontology_refs_for_alias(ontology, alias))
3230        .unwrap_or_default();
3231    CompactSymbolRefPreview {
3232        handle: stable_handle(prefix, key),
3233        name: truncate_for_budget(name, max_bytes),
3234        tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
3235        ontology_refs,
3236    }
3237}
3238
3239fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
3240    let report = tagpath_ontology::load_project(root).ok()?;
3241    if report.tags.is_empty() {
3242        return None;
3243    }
3244    Some(TagOntologyPreviewContext {
3245        project_root: report.project_path,
3246        tags: report
3247            .tags
3248            .into_iter()
3249            .map(|tag| (tag.tag.clone(), tag))
3250            .collect(),
3251    })
3252}
3253
3254fn ontology_refs_for_alias(
3255    ontology: Option<&TagOntologyPreviewContext>,
3256    alias: &str,
3257) -> Vec<CompactOntologyRefPreview> {
3258    let Some(ontology) = ontology else {
3259        return Vec::new();
3260    };
3261    let mut seen = BTreeSet::new();
3262    alias
3263        .split('/')
3264        .flat_map(|part| part.split('.'))
3265        .map(str::trim)
3266        .filter(|tag| !tag.is_empty())
3267        .filter_map(|tag| {
3268            let key = tag.to_ascii_lowercase();
3269            if !seen.insert(key.clone()) {
3270                return None;
3271            }
3272            let ontology_tag = ontology.tags.get(&key)?;
3273            let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
3274            Some(CompactOntologyRefPreview {
3275                handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
3276                tag: ontology_tag.tag.clone(),
3277                path,
3278                title: ontology_tag.title.clone(),
3279                domain: ontology_tag.domain.clone(),
3280            })
3281        })
3282        .collect()
3283}
3284
3285fn relativize_ontology_path(path: &Path, root: &Path) -> String {
3286    path.strip_prefix(root)
3287        .unwrap_or(path)
3288        .to_string_lossy()
3289        .replace('\\', "/")
3290}
3291
3292fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
3293    match tag_alias {
3294        Some(alias) => format!("{handle} {name} tag:{alias}"),
3295        None => format!("{handle} {name}"),
3296    }
3297}
3298
3299fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
3300    match summary.tag_alias.as_deref() {
3301        Some(alias) => format!(
3302            "{} {} tag:{} expand:{}",
3303            summary.handle, summary.symbol, alias, summary.expand
3304        ),
3305        None => format!(
3306            "{} {} expand:{}",
3307            summary.handle, summary.symbol, summary.expand
3308        ),
3309    }
3310}
3311
3312fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
3313    match symbol.tag_alias.as_deref() {
3314        Some(alias) => format!("{}@{}", symbol.handle, alias),
3315        None => format!("{}@{}", symbol.handle, symbol.name),
3316    }
3317}
3318
3319pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
3320    let trimmed = input.trim();
3321    if trimmed.len() <= max_bytes {
3322        return trimmed.to_string();
3323    }
3324    if max_bytes <= 3 {
3325        return ".".repeat(max_bytes);
3326    }
3327
3328    let mut end = 0usize;
3329    for (idx, ch) in trimmed.char_indices() {
3330        let next = idx + ch.len_utf8();
3331        if next > max_bytes.saturating_sub(3) {
3332            break;
3333        }
3334        end = next;
3335    }
3336
3337    if end == 0 {
3338        "...".to_string()
3339    } else {
3340        format!("{}...", &trimmed[..end])
3341    }
3342}
3343
3344struct TokenCappedPreview {
3345    preview: Vec<SourceLinePreview>,
3346    capped_end: usize,
3347    was_capped: bool,
3348}
3349
3350fn build_token_capped_preview(
3351    all_lines: &[&str],
3352    start: usize,
3353    end: usize,
3354    max_bytes: usize,
3355    token_cap: usize,
3356) -> TokenCappedPreview {
3357    let mut preview = Vec::new();
3358    let mut accumulated_tokens = 0usize;
3359    let mut capped_end = end;
3360    let mut was_capped = false;
3361
3362    for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
3363        let truncated = truncate_for_budget(line, max_bytes);
3364        let line_tokens = estimated_tokens_from_bytes(truncated.len());
3365        if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
3366            capped_end = start + idx - 1;
3367            was_capped = true;
3368            break;
3369        }
3370        accumulated_tokens += line_tokens;
3371        preview.push(SourceLinePreview {
3372            line: start + idx,
3373            text: truncated,
3374        });
3375    }
3376
3377    TokenCappedPreview {
3378        preview,
3379        capped_end,
3380        was_capped,
3381    }
3382}
3383
3384pub(crate) fn abbreviate_kind(kind: &str) -> &str {
3385    match kind {
3386        "function" => "fn",
3387        "method" => "meth",
3388        "module" | "mod" => "mod",
3389        "struct" => "struct",
3390        "trait" => "trait",
3391        "impl" => "impl",
3392        "class" => "cls",
3393        "interface" => "iface",
3394        "type_alias" => "type",
3395        "data_class" => "data_cls",
3396        "sealed_class" => "sealed_cls",
3397        "enum_class" => "enum_cls",
3398        "companion_object" => "comp_obj",
3399        "object" => "obj",
3400        "heading" => "h",
3401        "code_block" => "code",
3402        "alias" => "alias",
3403        other => other,
3404    }
3405}
3406
3407pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
3408    match kind {
3409        "calls" => "c",
3410        "defines" => "d",
3411        "contains" => "ct",
3412        "imports" => "i",
3413        "mentions" => "m",
3414        "mentions_concept" => "mc",
3415        "mentions_entity" => "me",
3416        "semantic_relation" => "sr",
3417        "belongs_to" => "bt",
3418        "scopes_context" => "sctx",
3419        "scopes_source" => "ssrc",
3420        "requests_context" => "rctx",
3421        "explains_result" => "er",
3422        "tagged_concept" => "tc",
3423        "tagged_entity" => "te",
3424        "related_concept" => "relc",
3425        "handled_by" => "hb",
3426        "defines_route" => "dr",
3427        "handles_route" => "hr",
3428        "targets" => "tgt",
3429        "has_vector_handle" => "hv",
3430        "parent" => "p",
3431        "child" => "ch",
3432        "uses" => "u",
3433        "projects_source" => "psrc",
3434        "records_memory_source" => "rms",
3435        "records_memory_event" => "rme",
3436        "has_ast_span" => "ha",
3437        "represents_symbol" => "rs",
3438        "contains_embedded_symbol" => "ces",
3439        "embedded_in_fence" => "ef",
3440        "contains_markdown_block" => "cmb",
3441        "contains_embedded_code" => "cec",
3442        "enclosing_module" => "em",
3443        "enclosing_section" => "es",
3444        "previous_sibling" => "psib",
3445        "next_sibling" => "nsib",
3446        "explicit_depends_on" => "edo",
3447        "worker_result_follow_up" => "wrf",
3448        "shared_resource" => "shr",
3449        "community_member" => "cm",
3450        other => other,
3451    }
3452}
3453
3454pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
3455    match mt {
3456        "exact_name" => "exact",
3457        "all_tags" => "all_tags",
3458        "partial_tags" => "partial",
3459        other => other,
3460    }
3461}
3462
3463pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
3464    path.iter()
3465        .map(|n| n.name.as_str())
3466        .collect::<Vec<_>>()
3467        .join(" -> ")
3468}
3469
3470const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
3471
3472struct SearchHitGroup {
3473    path: String,
3474    first_rank: usize,
3475    top_score: f64,
3476    confidence: String,
3477    hits: usize,
3478    samples: Vec<String>,
3479}
3480
3481fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
3482    let snippet = compact_snippet(&hit.snippet)?;
3483    Some(match hit.location.as_deref() {
3484        Some(location) => format!("{location}: {snippet}"),
3485        None => snippet,
3486    })
3487}
3488
3489pub(crate) fn group_search_hits(
3490    hits: &[sift::SearchHit],
3491    root: &Path,
3492    absolute: bool,
3493) -> Vec<SearchHitGroup> {
3494    let mut positions = BTreeMap::new();
3495    let mut groups = Vec::new();
3496    for hit in hits {
3497        let path = if absolute {
3498            hit.path.clone()
3499        } else {
3500            relativize(&hit.path, root)
3501        };
3502        let entry = positions.entry(path.clone()).or_insert_with(|| {
3503            groups.push(SearchHitGroup {
3504                path: path.clone(),
3505                first_rank: hit.rank,
3506                top_score: hit.score,
3507                confidence: format!("{:?}", hit.confidence),
3508                hits: 0,
3509                samples: Vec::new(),
3510            });
3511            groups.len() - 1
3512        });
3513        let group = &mut groups[*entry];
3514        group.hits += 1;
3515        if hit.rank < group.first_rank {
3516            group.first_rank = hit.rank;
3517        }
3518        if hit.score > group.top_score {
3519            group.top_score = hit.score;
3520        }
3521        if let Some(sample) = format_search_sample(hit)
3522            && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
3523            && !group.samples.contains(&sample)
3524        {
3525            group.samples.push(sample);
3526        }
3527    }
3528    groups.sort_by_key(|group| group.first_rank);
3529    groups
3530}
3531
3532pub(crate) fn should_collapse_search_hits(
3533    hits: &[sift::SearchHit],
3534    root: &Path,
3535    absolute: bool,
3536) -> bool {
3537    let groups = group_search_hits(hits, root, absolute);
3538    let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
3539    max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
3540}
3541
3542pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
3543    let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
3544    for edge in edges {
3545        let key = edge.caller_file.as_str();
3546        let name = if use_callers {
3547            edge.caller_name.as_str()
3548        } else {
3549            edge.callee_name.as_str()
3550        };
3551        let names = grouped.entry(key).or_default();
3552        if !names.contains(&name) {
3553            names.push(name);
3554        }
3555    }
3556
3557    grouped
3558        .into_iter()
3559        .map(|(file, names)| format!("  {} ({}): {}", file, names.len(), names.join(", ")))
3560        .collect()
3561}
3562
3563pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
3564    let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
3565    for edge in edges {
3566        *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
3567    }
3568    let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
3569    max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
3570}
3571
3572fn resolve_query_index_target(
3573    root: &Path,
3574    path_hint: &Path,
3575    scope: Option<&str>,
3576) -> Result<SearchIndexTarget> {
3577    let cfg = config::Config::load(root)?;
3578    if let Some(scope_name) = scope {
3579        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3580            return Ok(SearchIndexTarget {
3581                label: format!("submodule `{}` index", scope.id),
3582                db_path: cfg.db_path_for(root, &scope.id),
3583                source_root: scope.source_root.clone(),
3584                scope_name: Some(scope.id.clone()),
3585                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3586            });
3587        }
3588        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3589            return Ok(cargo_package_index_target(root, package));
3590        }
3591        config::Config::resolve_submodule(root, scope_name)?;
3592    }
3593
3594    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3595        return Ok(SearchIndexTarget {
3596            label: format!("submodule `{}` index", scope.id),
3597            db_path: cfg.db_path_for(root, &scope.id),
3598            source_root: scope.source_root.clone(),
3599            scope_name: Some(scope.id.clone()),
3600            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3601        });
3602    }
3603
3604    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3605        return Ok(cargo_package_index_target(root, package));
3606    }
3607
3608    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3609        return Ok(SearchIndexTarget {
3610            label: format!("submodule `{}` index", scope.id),
3611            db_path: cfg.db_path_for(root, &scope.id),
3612            source_root: scope.source_root.clone(),
3613            scope_name: Some(scope.id.clone()),
3614            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3615        });
3616    }
3617
3618    let db_path = root.join(".tsift/index.db");
3619    if db_path.exists() {
3620        return Ok(SearchIndexTarget {
3621            label: "index".to_string(),
3622            db_path,
3623            source_root: root.to_path_buf(),
3624            scope_name: None,
3625            reindex_cmd: format!("tsift index {}", root.display()),
3626        });
3627    }
3628
3629    let scopes = config::Config::submodule_dirs(root)?;
3630    if scopes.is_empty() {
3631        return Ok(SearchIndexTarget {
3632            label: "index".to_string(),
3633            db_path,
3634            source_root: root.to_path_buf(),
3635            scope_name: None,
3636            reindex_cmd: format!("tsift index {}", root.display()),
3637        });
3638    }
3639
3640    let available_scopes = scopes
3641        .iter()
3642        .map(|scope| scope.id.as_str())
3643        .collect::<Vec<_>>()
3644        .join(", ");
3645    let indexed_scopes = scopes
3646        .iter()
3647        .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3648        .map(|scope| scope.id.as_str())
3649        .collect::<Vec<_>>();
3650    let indexed_label = if indexed_scopes.is_empty() {
3651        "none".to_string()
3652    } else {
3653        indexed_scopes.join(", ")
3654    };
3655
3656    bail!(
3657        "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: {}.",
3658        root.display(),
3659        db_path.display(),
3660        available_scopes,
3661        indexed_label
3662    );
3663}
3664
3665pub(crate) fn resolve_query_db_path(
3666    root: &Path,
3667    path_hint: &Path,
3668    scope: Option<&str>,
3669) -> Result<PathBuf> {
3670    Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3671}
3672
3673fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3674    let state = inspect_search_index(target)?;
3675    let Some(reason) = index_reason_for_state(state) else {
3676        return Ok(());
3677    };
3678
3679    match apply_search_index_update(root, target) {
3680        Ok(_) => {
3681            index::inspect_scope_invalidate_all();
3682            Ok(())
3683        }
3684        Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3685            eprintln!(
3686                "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3687                 Continuing with the current read-only index snapshot; graph results may lag. \
3688                 Retry `{}` after the active writer finishes for fresh graph results.",
3689                index_reason_detail(target, reason),
3690                target.reindex_cmd
3691            );
3692            Ok(())
3693        }
3694        Err(err) => Err(err),
3695    }
3696}
3697
3698pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3699    let root = lint::resolve_project_root_or_canonical_path(path)?;
3700    let target = resolve_query_index_target(&root, path, scope)?;
3701    ensure_query_index_current(&root, &target)?;
3702    let db_path = target.db_path;
3703    if !db_path.exists() {
3704        bail!(
3705            "no index found at {}. Run `tsift index` first.",
3706            db_path.display()
3707        );
3708    }
3709    index::IndexDb::open_read_only_resilient(&db_path)
3710}
3711
3712pub(crate) fn query_tagpath_root(
3713    root: &std::path::Path,
3714    path_hint: &std::path::Path,
3715    scope: Option<&str>,
3716) -> Result<PathBuf> {
3717    if let Some(scope_name) = scope {
3718        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3719            return Ok(scope.source_root);
3720        }
3721        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3722            return Ok(package.package_root);
3723        }
3724        config::Config::resolve_submodule(root, scope_name)?;
3725    }
3726    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3727        return Ok(scope.source_root);
3728    }
3729    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3730        return Ok(package.package_root);
3731    }
3732    Ok(root.to_path_buf())
3733}
3734
3735#[derive(Clone, Debug, Serialize, PartialEq)]
3736struct TraversalNode {
3737    handle: String,
3738    kind: String,
3739    label: String,
3740    #[serde(skip_serializing_if = "Option::is_none")]
3741    ref_id: Option<String>,
3742    #[serde(skip_serializing_if = "Option::is_none")]
3743    path: Option<String>,
3744    #[serde(skip_serializing_if = "Option::is_none")]
3745    line: Option<i64>,
3746    #[serde(skip_serializing_if = "Option::is_none")]
3747    detail: Option<String>,
3748    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3749    properties: BTreeMap<String, String>,
3750    expand: String,
3751}
3752
3753#[derive(Clone, Debug, Serialize, PartialEq)]
3754struct TraversalEdge {
3755    from: String,
3756    to: String,
3757    relation: String,
3758    #[serde(skip_serializing_if = "Option::is_none")]
3759    label: Option<String>,
3760    weight: usize,
3761}
3762
3763#[derive(Clone, Debug, Default)]
3764struct TraversalGraphBuild {
3765    nodes: BTreeMap<String, TraversalNode>,
3766    edges: Vec<TraversalEdge>,
3767    edge_keys: BTreeSet<(String, String, String)>,
3768    warnings: Vec<String>,
3769}
3770
3771pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3772const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3773const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3774const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3775const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3776    "context-pack-graph-orchestration-v1";
3777const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3778const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3779const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3780const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3781const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3782const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3783const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3784
3785#[derive(Debug, Serialize, PartialEq)]
3786struct TraversalTotals {
3787    nodes: usize,
3788    edges: usize,
3789}
3790
3791#[derive(Debug, Serialize, PartialEq)]
3792struct TraversalPathReport {
3793    from: TraversalNode,
3794    to: TraversalNode,
3795    hops: usize,
3796    nodes: Vec<TraversalNode>,
3797    edges: Vec<TraversalEdge>,
3798}
3799
3800#[derive(Debug, Serialize, PartialEq)]
3801struct TraversalRecommendation {
3802    handle: String,
3803    kind: String,
3804    label: String,
3805    reason: String,
3806    score: usize,
3807    expand: String,
3808}
3809
3810#[derive(Debug, Serialize, PartialEq)]
3811struct TraversalReport {
3812    root: String,
3813    #[serde(skip_serializing_if = "Option::is_none")]
3814    scope: Option<String>,
3815    mode: String,
3816    totals: TraversalTotals,
3817    #[serde(skip_serializing_if = "Option::is_none")]
3818    query: Option<String>,
3819    #[serde(skip_serializing_if = "Option::is_none")]
3820    target: Option<String>,
3821    nodes: Vec<TraversalNode>,
3822    edges: Vec<TraversalEdge>,
3823    #[serde(skip_serializing_if = "Option::is_none")]
3824    shortest_path: Option<TraversalPathReport>,
3825    recommendations: Vec<TraversalRecommendation>,
3826    exploration: ExplorationPacket,
3827    truncated: bool,
3828    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3829    warnings: Vec<String>,
3830}
3831
3832#[derive(Debug, Serialize, PartialEq)]
3833struct SemanticRelatedReport {
3834    root: String,
3835    #[serde(skip_serializing_if = "Option::is_none")]
3836    scope: Option<String>,
3837    query: String,
3838    embedding_model: String,
3839    count: usize,
3840    items: Vec<SemanticRelatedItem>,
3841    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3842    warnings: Vec<String>,
3843}
3844
3845#[derive(Clone, Debug, Serialize, PartialEq)]
3846struct SemanticRelatedItem {
3847    handle: String,
3848    kind: String,
3849    label: String,
3850    score: f64,
3851    #[serde(skip_serializing_if = "Option::is_none")]
3852    file_path: Option<String>,
3853    #[serde(skip_serializing_if = "Option::is_none")]
3854    source_symbol: Option<String>,
3855    #[serde(skip_serializing_if = "Option::is_none")]
3856    detail: Option<String>,
3857    expand: String,
3858}
3859
3860#[derive(Clone)]
3861struct TraversalSymbolIndexEntry {
3862    handle: String,
3863    node: TraversalNode,
3864    tokens: BTreeSet<String>,
3865}
3866
3867#[derive(Clone)]
3868struct TraversalFileIndexEntry {
3869    handle: String,
3870    node: TraversalNode,
3871    tokens: BTreeSet<String>,
3872}
3873
3874#[derive(Clone)]
3875struct TraversalRouteIndexEntry {
3876    handle: String,
3877    node: TraversalNode,
3878    tokens: BTreeSet<String>,
3879}
3880
3881#[derive(Clone)]
3882struct TraversalAstSpanIndexEntry {
3883    handle: String,
3884    symbol_handle: String,
3885    file_handle: Option<String>,
3886    file: String,
3887    name: String,
3888    kind: String,
3889    language: String,
3890    node_kind: String,
3891    start_byte: usize,
3892    end_byte: usize,
3893    parent_module: Option<String>,
3894    markdown: Option<MarkdownSpanMetadata>,
3895}
3896
3897#[derive(Clone)]
3898struct TraversalMultiplicityIndexEntry {
3899    handle: String,
3900    node: TraversalNode,
3901    tokens: BTreeSet<String>,
3902}
3903
3904struct TraversalCodeLookup<'a> {
3905    symbols: &'a [TraversalSymbolIndexEntry],
3906    files: &'a [TraversalFileIndexEntry],
3907    routes: &'a [TraversalRouteIndexEntry],
3908    multiplicities: &'a [TraversalMultiplicityIndexEntry],
3909    symbol_index: HashMap<String, Vec<usize>>,
3910    file_index: HashMap<String, Vec<usize>>,
3911    route_index: HashMap<String, Vec<usize>>,
3912    multiplicity_index: HashMap<String, Vec<usize>>,
3913    file_path_index: HashMap<String, String>,
3914}
3915
3916#[derive(Clone, Debug, Serialize, PartialEq)]
3917struct ExplorationBudget {
3918    project_size: String,
3919    max_source_windows: usize,
3920    lines_per_window: usize,
3921    relationship_limit: usize,
3922}
3923
3924#[derive(Clone, Debug, Serialize, PartialEq)]
3925struct ExplorationRelation {
3926    from: String,
3927    relation: String,
3928    to: String,
3929    #[serde(skip_serializing_if = "Option::is_none")]
3930    label: Option<String>,
3931}
3932
3933#[derive(Clone, Debug, Serialize, PartialEq)]
3934struct ExplorationSourceWindow {
3935    handle: String,
3936    file: String,
3937    start: usize,
3938    end: usize,
3939    reason: String,
3940    expand: String,
3941}
3942
3943#[derive(Clone, Debug, Serialize, PartialEq)]
3944struct ExplorationWorkerContext {
3945    handle: String,
3946    target: String,
3947    summary: String,
3948    expand: String,
3949}
3950
3951#[derive(Clone, Debug, Serialize, PartialEq)]
3952struct ExplorationPacket {
3953    budget: ExplorationBudget,
3954    relationship_map: Vec<ExplorationRelation>,
3955    source_windows: Vec<ExplorationSourceWindow>,
3956    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3957    worker_context: Vec<ExplorationWorkerContext>,
3958    no_reread_guidance: String,
3959}
3960
3961impl TraversalGraphBuild {
3962    fn add_node(&mut self, node: TraversalNode) {
3963        self.nodes.entry(node.handle.clone()).or_insert(node);
3964    }
3965
3966    fn add_edge(
3967        &mut self,
3968        from: &str,
3969        to: &str,
3970        relation: &str,
3971        label: Option<String>,
3972        weight: usize,
3973    ) {
3974        if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3975            return;
3976        }
3977        let key = (from.to_string(), to.to_string(), relation.to_string());
3978        if self.edge_keys.insert(key) {
3979            self.edges.push(TraversalEdge {
3980                from: from.to_string(),
3981                to: to.to_string(),
3982                relation: relation.to_string(),
3983                label,
3984                weight,
3985            });
3986        }
3987    }
3988}
3989
3990pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3991    match scope {
3992        Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3993        None => root.join(".tsift/graph.db"),
3994    }
3995}
3996
3997fn graph_projection_meta_id(scope: Option<&str>) -> String {
3998    format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3999}
4000
4001pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
4002    let bytes = serde_json::to_vec(value)?;
4003    Ok(blake3::hash(&bytes).to_hex().to_string())
4004}
4005
4006fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
4007    let mut hashable = node.clone();
4008    hashable.freshness = None;
4009    node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4010    Ok(node)
4011}
4012
4013fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
4014    let mut hashable = edge.clone();
4015    hashable.freshness = None;
4016    edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4017    Ok(edge)
4018}
4019
4020const SEMANTIC_EMBEDDING_DIM: usize = 32;
4021const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
4022
4023fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
4024    match kind {
4025        SemanticRelatedKind::Concept => "concept",
4026        SemanticRelatedKind::Entity => "entity",
4027        SemanticRelatedKind::All => "all",
4028    }
4029}
4030
4031fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
4032    format!(
4033        "tsift semantic {} --path {} --kind {} --limit 10",
4034        shell_quote(query),
4035        shell_quote(root.to_string_lossy().as_ref()),
4036        semantic_related_kind_name(kind)
4037    )
4038}
4039
4040fn semantic_embedding(input: &str) -> Vec<f64> {
4041    let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
4042    let mut tokens = traversal_tokens(input);
4043    if tokens.is_empty() {
4044        let trimmed = input.trim().to_ascii_lowercase();
4045        if !trimmed.is_empty() {
4046            tokens.insert(trimmed);
4047        }
4048    }
4049
4050    for token in tokens {
4051        let hash = blake3::hash(token.as_bytes());
4052        let bytes = hash.as_bytes();
4053        let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
4054        let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
4055        vector[idx] += sign;
4056    }
4057
4058    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
4059    if norm > 0.0 {
4060        for value in &mut vector {
4061            *value /= norm;
4062        }
4063    }
4064    vector
4065}
4066
4067fn semantic_embedding_property(input: &str) -> String {
4068    semantic_embedding(input)
4069        .iter()
4070        .map(|value| format!("{value:.6}"))
4071        .collect::<Vec<_>>()
4072        .join(",")
4073}
4074
4075fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
4076    let parsed = value
4077        .split(',')
4078        .map(str::trim)
4079        .map(str::parse::<f64>)
4080        .collect::<std::result::Result<Vec<_>, _>>()
4081        .ok()?;
4082    (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
4083}
4084
4085fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
4086    if left.len() != right.len() {
4087        return 0.0;
4088    }
4089    left.iter()
4090        .zip(right.iter())
4091        .map(|(left, right)| left * right)
4092        .sum::<f64>()
4093}
4094
4095fn semantic_entity_handle(name: &str, kind: &str) -> String {
4096    stable_handle(
4097        "gent",
4098        &format!(
4099            "entity:{}:{}",
4100            kind.trim().to_ascii_lowercase(),
4101            name.trim().to_ascii_lowercase()
4102        ),
4103    )
4104}
4105
4106fn semantic_concept_handle(label: &str) -> String {
4107    stable_handle(
4108        "gcon",
4109        &format!("concept:{}", label.trim().to_ascii_lowercase()),
4110    )
4111}
4112
4113fn summary_source_handles(
4114    summary: &summarize::Summary,
4115    file_node_by_path: &BTreeMap<String, String>,
4116    symbol_node_by_file_label: &BTreeMap<(String, String), String>,
4117) -> Vec<String> {
4118    let mut handles = Vec::new();
4119    if let Some(handle) = file_node_by_path.get(&summary.file_path) {
4120        handles.push(handle.clone());
4121    }
4122    if let Some(handle) =
4123        symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
4124        && !handles.iter().any(|existing| existing == handle)
4125    {
4126        handles.push(handle.clone());
4127    }
4128    handles
4129}
4130
4131fn semantic_entity_node(
4132    root: &Path,
4133    summary: &summarize::Summary,
4134    name: &str,
4135    kind: &str,
4136    description: &str,
4137    provenance: &GraphProvenance,
4138) -> SubstrateGraphNode {
4139    let handle = semantic_entity_handle(name, kind);
4140    let detail = if description.trim().is_empty() {
4141        format!("{kind} entity from cached summaries")
4142    } else {
4143        format!("{kind}: {description}")
4144    };
4145    SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
4146        .with_property("handle", handle)
4147        .with_property("ref_id", name.to_string())
4148        .with_property("detail", detail)
4149        .with_property("entity_kind", kind.to_string())
4150        .with_property("description", description.to_string())
4151        .with_property("source_file", summary.file_path.clone())
4152        .with_property("source_symbol", summary.symbol_name.clone())
4153        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4154        .with_property(
4155            "embedding",
4156            semantic_embedding_property(&format!("{name} {kind} {description}")),
4157        )
4158        .with_property(
4159            "expand",
4160            semantic_related_command(root, name, SemanticRelatedKind::Entity),
4161        )
4162        .with_provenance(provenance.clone())
4163}
4164
4165fn semantic_concept_node(
4166    root: &Path,
4167    summary: &summarize::Summary,
4168    label: &str,
4169    provenance: &GraphProvenance,
4170) -> SubstrateGraphNode {
4171    let handle = semantic_concept_handle(label);
4172    SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
4173        .with_property("handle", handle)
4174        .with_property("ref_id", label.to_string())
4175        .with_property("detail", "concept label from cached summaries".to_string())
4176        .with_property("source_file", summary.file_path.clone())
4177        .with_property("source_symbol", summary.symbol_name.clone())
4178        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4179        .with_property("embedding", semantic_embedding_property(label))
4180        .with_property(
4181            "expand",
4182            semantic_related_command(root, label, SemanticRelatedKind::Concept),
4183        )
4184        .with_provenance(provenance.clone())
4185}
4186
4187fn insert_semantic_edge(
4188    edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
4189    edge: SubstrateGraphEdge,
4190) {
4191    edge_map
4192        .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
4193        .or_insert(edge);
4194}
4195
4196fn append_summary_semantic_projection_rows(
4197    root: &Path,
4198    graph: &TraversalGraphBuild,
4199    provenance: &GraphProvenance,
4200    nodes: &mut Vec<SubstrateGraphNode>,
4201    edges: &mut Vec<SubstrateGraphEdge>,
4202) -> Result<()> {
4203    let summaries_db = root.join(".tsift/summaries.db");
4204    if !summaries_db.exists() {
4205        return Ok(());
4206    }
4207
4208    let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
4209    let summaries = summary_db.all()?;
4210    if summaries.is_empty() {
4211        return Ok(());
4212    }
4213
4214    let file_node_by_path = graph
4215        .nodes
4216        .values()
4217        .filter(|node| node.kind == "file")
4218        .filter_map(|node| {
4219            node.path
4220                .as_ref()
4221                .map(|path| (path.clone(), node.handle.clone()))
4222        })
4223        .collect::<BTreeMap<_, _>>();
4224    let symbol_node_by_file_label = graph
4225        .nodes
4226        .values()
4227        .filter(|node| node.kind == "symbol")
4228        .filter_map(|node| {
4229            Some((
4230                (node.path.clone()?, node.label.clone()),
4231                node.handle.clone(),
4232            ))
4233        })
4234        .collect::<BTreeMap<_, _>>();
4235
4236    let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
4237    let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
4238
4239    for summary in &summaries {
4240        let source_handles =
4241            summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
4242        let mut entity_ids_by_name = BTreeMap::<String, String>::new();
4243
4244        if let Some(entities) = &summary.entities {
4245            for entity in entities {
4246                let node = semantic_entity_node(
4247                    root,
4248                    summary,
4249                    &entity.name,
4250                    &entity.kind,
4251                    &entity.description,
4252                    provenance,
4253                );
4254                let entity_id = node.id.clone();
4255                entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
4256                semantic_nodes.entry(entity_id.clone()).or_insert(node);
4257
4258                for source_handle in &source_handles {
4259                    insert_semantic_edge(
4260                        &mut semantic_edges,
4261                        SubstrateGraphEdge::new(
4262                            source_handle.clone(),
4263                            entity_id.clone(),
4264                            "mentions_entity",
4265                        )
4266                        .with_property("label", format!("summary entity: {}", entity.name))
4267                        .with_property("source_file", summary.file_path.clone())
4268                        .with_provenance(provenance.clone()),
4269                    );
4270                }
4271            }
4272        }
4273
4274        let mut concept_ids = Vec::new();
4275        if let Some(labels) = &summary.concept_labels {
4276            for label in labels
4277                .iter()
4278                .map(|label| label.trim())
4279                .filter(|label| !label.is_empty())
4280            {
4281                let node = semantic_concept_node(root, summary, label, provenance);
4282                let concept_id = node.id.clone();
4283                semantic_nodes.entry(concept_id.clone()).or_insert(node);
4284                concept_ids.push(concept_id.clone());
4285
4286                for source_handle in &source_handles {
4287                    insert_semantic_edge(
4288                        &mut semantic_edges,
4289                        SubstrateGraphEdge::new(
4290                            source_handle.clone(),
4291                            concept_id.clone(),
4292                            "mentions_concept",
4293                        )
4294                        .with_property("label", format!("summary concept: {label}"))
4295                        .with_property("source_file", summary.file_path.clone())
4296                        .with_provenance(provenance.clone()),
4297                    );
4298                }
4299            }
4300        }
4301
4302        for entity_id in entity_ids_by_name.values() {
4303            for concept_id in &concept_ids {
4304                insert_semantic_edge(
4305                    &mut semantic_edges,
4306                    SubstrateGraphEdge::new(
4307                        entity_id.clone(),
4308                        concept_id.clone(),
4309                        "tagged_concept",
4310                    )
4311                    .with_property("label", "entity concept label".to_string())
4312                    .with_property("source_file", summary.file_path.clone())
4313                    .with_provenance(provenance.clone()),
4314                );
4315            }
4316        }
4317
4318        for idx in 0..concept_ids.len() {
4319            for next_idx in (idx + 1)..concept_ids.len() {
4320                insert_semantic_edge(
4321                    &mut semantic_edges,
4322                    SubstrateGraphEdge::new(
4323                        concept_ids[idx].clone(),
4324                        concept_ids[next_idx].clone(),
4325                        "related_concept",
4326                    )
4327                    .with_property("label", format!("co-occurs in {}", summary.symbol_name))
4328                    .with_property("source_file", summary.file_path.clone())
4329                    .with_provenance(provenance.clone()),
4330                );
4331            }
4332        }
4333
4334        if let Some(relationships) = &summary.relationships {
4335            for relationship in relationships {
4336                let from_id = entity_ids_by_name
4337                    .get(&relationship.from.to_ascii_lowercase())
4338                    .cloned()
4339                    .unwrap_or_else(|| {
4340                        let node = semantic_entity_node(
4341                            root,
4342                            summary,
4343                            &relationship.from,
4344                            "unknown",
4345                            "",
4346                            provenance,
4347                        );
4348                        let id = node.id.clone();
4349                        semantic_nodes.entry(id.clone()).or_insert(node);
4350                        id
4351                    });
4352                let to_id = entity_ids_by_name
4353                    .get(&relationship.to.to_ascii_lowercase())
4354                    .cloned()
4355                    .unwrap_or_else(|| {
4356                        let node = semantic_entity_node(
4357                            root,
4358                            summary,
4359                            &relationship.to,
4360                            "unknown",
4361                            "",
4362                            provenance,
4363                        );
4364                        let id = node.id.clone();
4365                        semantic_nodes.entry(id.clone()).or_insert(node);
4366                        id
4367                    });
4368                insert_semantic_edge(
4369                    &mut semantic_edges,
4370                    SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
4371                        .with_property("relationship_kind", relationship.kind.clone())
4372                        .with_property("label", relationship.kind.clone())
4373                        .with_property("source_file", summary.file_path.clone())
4374                        .with_property("source_symbol", summary.symbol_name.clone())
4375                        .with_provenance(provenance.clone()),
4376                );
4377            }
4378        }
4379    }
4380
4381    for node in semantic_nodes.into_values() {
4382        nodes.push(node_with_content_freshness(node)?);
4383    }
4384    for edge in semantic_edges.into_values() {
4385        edges.push(edge_with_content_freshness(edge)?);
4386    }
4387
4388    Ok(())
4389}
4390
4391fn projection_content_hash(
4392    nodes: &[SubstrateGraphNode],
4393    edges: &[SubstrateGraphEdge],
4394) -> Result<String> {
4395    #[derive(Serialize)]
4396    struct Payload<'a> {
4397        version: &'static str,
4398        nodes: &'a [SubstrateGraphNode],
4399        edges: &'a [SubstrateGraphEdge],
4400    }
4401
4402    content_hash(&Payload {
4403        version: GRAPH_PROJECTION_VERSION,
4404        nodes,
4405        edges,
4406    })
4407}
4408
4409pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
4410    projection
4411        .nodes
4412        .iter()
4413        .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
4414        .and_then(|node| node.properties.get("content_hash").cloned())
4415}
4416
4417fn traversal_projection_from_graph(
4418    root: &Path,
4419    scope: Option<&str>,
4420    graph: &TraversalGraphBuild,
4421) -> Result<GraphProjection> {
4422    let provenance = GraphProvenance::new(
4423        "tsift.traverse",
4424        format!("{}:{}", root.display(), scope.unwrap_or("root")),
4425    );
4426    let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
4427    for node in graph.nodes.values() {
4428        let mut projected =
4429            SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
4430                .with_property("handle", node.handle.clone())
4431                .with_property("expand", node.expand.clone())
4432                .with_provenance(provenance.clone());
4433        if let Some(ref_id) = &node.ref_id {
4434            projected = projected.with_property("ref_id", ref_id.clone());
4435        }
4436        if let Some(path) = &node.path {
4437            projected = projected.with_property("path", path.clone());
4438        }
4439        if let Some(line) = node.line {
4440            projected = projected.with_property("line", line.to_string());
4441        }
4442        if let Some(detail) = &node.detail {
4443            projected = projected.with_property("detail", detail.clone());
4444        }
4445        for (key, value) in &node.properties {
4446            projected = projected.with_property(key.clone(), value.clone());
4447        }
4448        nodes.push(node_with_content_freshness(projected)?);
4449    }
4450
4451    let mut edges = Vec::with_capacity(graph.edges.len());
4452    for edge in &graph.edges {
4453        let mut projected =
4454            SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
4455                .with_property("weight", edge.weight.to_string())
4456                .with_provenance(provenance.clone());
4457        if let Some(label) = &edge.label {
4458            projected = projected.with_property("label", label.clone());
4459        }
4460        edges.push(edge_with_content_freshness(projected)?);
4461    }
4462
4463    append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4464    append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4465    append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
4466
4467    let projection_hash = projection_content_hash(&nodes, &edges)?;
4468    let meta = SubstrateGraphNode::new(
4469        graph_projection_meta_id(scope),
4470        GRAPH_PROJECTION_META_KIND,
4471        "tsift traversal projection",
4472    )
4473    .with_property("projection_version", GRAPH_PROJECTION_VERSION)
4474    .with_property("content_hash", projection_hash.clone())
4475    .with_property("root", root.to_string_lossy().to_string())
4476    .with_property("scope", scope.unwrap_or("root"))
4477    .with_property("node_count", graph.nodes.len().to_string())
4478    .with_property("edge_count", graph.edges.len().to_string())
4479    .with_provenance(provenance)
4480    .with_freshness(GraphFreshness::content_hash(projection_hash));
4481    nodes.push(meta);
4482
4483    Ok(GraphProjection { nodes, edges })
4484}
4485
4486#[allow(clippy::too_many_arguments)]
4487fn ensure_traversal_source_handle(
4488    root: &Path,
4489    provenance: &GraphProvenance,
4490    file_node_by_path: &BTreeMap<String, String>,
4491    node: &TraversalNode,
4492    budget: &ExplorationBudget,
4493    source_handle_by_node: &mut BTreeMap<String, String>,
4494    seen_windows: &mut BTreeMap<(String, usize, usize), String>,
4495    nodes: &mut Vec<SubstrateGraphNode>,
4496    edges: &mut Vec<SubstrateGraphEdge>,
4497) -> Result<Option<String>> {
4498    if let Some(handle) = source_handle_by_node.get(&node.handle) {
4499        return Ok(Some(handle.clone()));
4500    }
4501    let Some(window) = exploration_source_window_for_node(root, node, budget) else {
4502        return Ok(None);
4503    };
4504    let window_key = (window.file.clone(), window.start, window.end);
4505    let handle = if let Some(handle) = seen_windows.get(&window_key) {
4506        handle.clone()
4507    } else {
4508        let label = format!("{}:{}-{}", window.file, window.start, window.end);
4509        let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
4510            .with_property("handle", window.handle.clone())
4511            .with_property("file", window.file.clone())
4512            .with_property("start", window.start.to_string())
4513            .with_property("end", window.end.to_string())
4514            .with_property("reason", window.reason.clone())
4515            .with_property("expand", window.expand.clone())
4516            .with_provenance(provenance.clone());
4517        nodes.push(node_with_content_freshness(projected)?);
4518
4519        if let Some(file_handle) = file_node_by_path.get(&window.file) {
4520            let edge = SubstrateGraphEdge::new(
4521                window.handle.clone(),
4522                file_handle.clone(),
4523                "expands_source",
4524            )
4525            .with_property("label", window.reason.clone())
4526            .with_provenance(provenance.clone());
4527            edges.push(edge_with_content_freshness(edge)?);
4528        }
4529        if node.kind != "file" {
4530            let edge = SubstrateGraphEdge::new(
4531                window.handle.clone(),
4532                node.handle.clone(),
4533                "anchors_source",
4534            )
4535            .with_property("label", window.reason.clone())
4536            .with_provenance(provenance.clone());
4537            edges.push(edge_with_content_freshness(edge)?);
4538        }
4539        seen_windows.insert(window_key, window.handle.clone());
4540        window.handle
4541    };
4542    source_handle_by_node.insert(node.handle.clone(), handle.clone());
4543    Ok(Some(handle))
4544}
4545
4546fn push_traversal_backlog_target_handles<'a>(
4547    backlog: &TraversalNode,
4548    edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
4549    node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
4550    max_handles: usize,
4551    seen_target_nodes: &mut BTreeSet<String>,
4552    target_node_handles: &mut Vec<String>,
4553) {
4554    for edge in edges_by_from
4555        .get(backlog.handle.as_str())
4556        .into_iter()
4557        .flatten()
4558        .filter(|edge| edge.relation == "mentions")
4559    {
4560        let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
4561            continue;
4562        };
4563        if !matches!(
4564            target_node.kind.as_str(),
4565            "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
4566        ) {
4567            continue;
4568        }
4569        if target_node
4570            .path
4571            .as_deref()
4572            .zip(backlog.path.as_deref())
4573            .is_some_and(|(target_path, backlog_path)| {
4574                target_path == backlog_path && target_path.ends_with(".md")
4575            })
4576        {
4577            continue;
4578        }
4579        if seen_target_nodes.insert(target_node.handle.clone()) {
4580            target_node_handles.push(target_node.handle.clone());
4581        }
4582        if target_node_handles.len() >= max_handles {
4583            break;
4584        }
4585    }
4586}
4587
4588fn append_traversal_context_projection_rows(
4589    root: &Path,
4590    graph: &TraversalGraphBuild,
4591    provenance: &GraphProvenance,
4592    nodes: &mut Vec<SubstrateGraphNode>,
4593    edges: &mut Vec<SubstrateGraphEdge>,
4594) -> Result<()> {
4595    let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4596    let file_node_by_path = graph
4597        .nodes
4598        .values()
4599        .filter(|node| node.kind == "file")
4600        .filter_map(|node| {
4601            node.path
4602                .as_ref()
4603                .map(|path| (path.clone(), node.handle.clone()))
4604        })
4605        .collect::<BTreeMap<_, _>>();
4606
4607    let node_by_handle = graph
4608        .nodes
4609        .values()
4610        .map(|node| (node.handle.as_str(), node))
4611        .collect::<BTreeMap<_, _>>();
4612    let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4613    for edge in &graph.edges {
4614        edges_by_from
4615            .entry(edge.from.as_str())
4616            .or_default()
4617            .push(edge);
4618    }
4619    for rows in edges_by_from.values_mut() {
4620        rows.sort_by(|left, right| {
4621            right
4622                .weight
4623                .cmp(&left.weight)
4624                .then(left.relation.cmp(&right.relation))
4625                .then(left.to.cmp(&right.to))
4626        });
4627    }
4628
4629    let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4630    let mut source_handle_by_node = BTreeMap::<String, String>::new();
4631
4632    let mut code_context_count = 0usize;
4633    let code_context_limit = budget.relationship_limit.min(8);
4634    for node in graph.nodes.values() {
4635        if !matches!(
4636            node.kind.as_str(),
4637            "backlog" | "job_packet" | "worker_result"
4638        ) {
4639            continue;
4640        }
4641        let mut target_node_handles = Vec::new();
4642        let mut fallback_target_handles = Vec::new();
4643        let mut seen_target_nodes = BTreeSet::new();
4644        if node.kind == "backlog" || node.kind == "worker_result" {
4645            push_traversal_backlog_target_handles(
4646                node,
4647                &edges_by_from,
4648                &node_by_handle,
4649                budget.max_source_windows,
4650                &mut seen_target_nodes,
4651                &mut target_node_handles,
4652            );
4653            fallback_target_handles.push(node.handle.clone());
4654        } else {
4655            for edge in edges_by_from
4656                .get(node.handle.as_str())
4657                .into_iter()
4658                .flatten()
4659                .filter(|edge| edge.relation == "targets")
4660            {
4661                let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4662                    continue;
4663                };
4664                fallback_target_handles.push(backlog.handle.clone());
4665                push_traversal_backlog_target_handles(
4666                    backlog,
4667                    &edges_by_from,
4668                    &node_by_handle,
4669                    budget.max_source_windows,
4670                    &mut seen_target_nodes,
4671                    &mut target_node_handles,
4672                );
4673                if target_node_handles.len() >= budget.max_source_windows {
4674                    break;
4675                }
4676            }
4677            if fallback_target_handles.is_empty() {
4678                continue;
4679            }
4680        }
4681        let code_context = !target_node_handles.is_empty();
4682        if target_node_handles.is_empty() {
4683            target_node_handles = dedupe_preserve_order(fallback_target_handles);
4684        } else if code_context_count >= code_context_limit {
4685            continue;
4686        }
4687
4688        let mut worker_source_handles = Vec::new();
4689        let mut seen_worker_handles = BTreeSet::new();
4690        for target_handle in target_node_handles {
4691            if worker_source_handles.len() >= budget.max_source_windows {
4692                break;
4693            }
4694            let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4695                continue;
4696            };
4697            let Some(handle) = ensure_traversal_source_handle(
4698                root,
4699                provenance,
4700                &file_node_by_path,
4701                target_node,
4702                &budget,
4703                &mut source_handle_by_node,
4704                &mut seen_windows,
4705                nodes,
4706                edges,
4707            )?
4708            else {
4709                continue;
4710            };
4711            if seen_worker_handles.insert(handle.clone()) {
4712                worker_source_handles.push(handle);
4713            }
4714        }
4715        if worker_source_handles.is_empty() {
4716            continue;
4717        }
4718        let target = node
4719            .path
4720            .clone()
4721            .unwrap_or_else(|| root.to_string_lossy().to_string());
4722        let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4723        let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4724        let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4725            .with_property("handle", handle.clone())
4726            .with_property("target", target.clone())
4727            .with_property("summary", summary)
4728            .with_property(
4729                "source_handle_count",
4730                worker_source_handles.len().to_string(),
4731            )
4732            .with_property(
4733                "expand",
4734                format!(
4735                    "tsift --envelope context-pack {} --budget normal",
4736                    shell_quote(&target)
4737                ),
4738            )
4739            .with_provenance(provenance.clone());
4740        nodes.push(node_with_content_freshness(projected)?);
4741
4742        let request_edge =
4743            SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4744                .with_property("label", "bounded worker context".to_string())
4745                .with_provenance(provenance.clone());
4746        edges.push(edge_with_content_freshness(request_edge)?);
4747
4748        for source_handle in &worker_source_handles {
4749            let scope_edge =
4750                SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4751                    .with_property("label", "bounded worker source window".to_string())
4752                    .with_provenance(provenance.clone());
4753            edges.push(edge_with_content_freshness(scope_edge)?);
4754        }
4755        if code_context {
4756            code_context_count += 1;
4757        }
4758    }
4759
4760    Ok(())
4761}
4762
4763fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4764    let handle = node
4765        .properties
4766        .get("handle")
4767        .cloned()
4768        .unwrap_or_else(|| node.id.clone());
4769    TraversalNode {
4770        expand: node
4771            .properties
4772            .get("expand")
4773            .cloned()
4774            .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4775        handle,
4776        kind: node.kind,
4777        label: node.label,
4778        ref_id: node.properties.get("ref_id").cloned(),
4779        path: node.properties.get("path").cloned(),
4780        line: node
4781            .properties
4782            .get("line")
4783            .and_then(|value| value.parse::<i64>().ok()),
4784        detail: node.properties.get("detail").cloned(),
4785        properties: node.properties,
4786    }
4787}
4788
4789fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4790    let mut graph = TraversalGraphBuild::default();
4791    for node in store.all_nodes()? {
4792        if node.kind == GRAPH_PROJECTION_META_KIND {
4793            continue;
4794        }
4795        graph.add_node(traversal_node_from_graph_node(root, node));
4796    }
4797    for edge in store.all_edges()? {
4798        graph.add_edge(
4799            &edge.from_id,
4800            &edge.to_id,
4801            &edge.kind,
4802            edge.properties.get("label").cloned(),
4803            edge.properties
4804                .get("weight")
4805                .and_then(|value| value.parse::<usize>().ok())
4806                .unwrap_or(1),
4807        );
4808    }
4809    Ok(graph)
4810}
4811
4812pub(crate) fn convex_rows_from_graph_store(
4813    store: &impl GraphStore,
4814) -> Result<ConvexProjectionRows> {
4815    Ok(GraphProjection {
4816        nodes: store.all_nodes()?,
4817        edges: store.all_edges()?,
4818    }
4819    .to_convex_rows())
4820}
4821
4822#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4823struct ConvexRequiredIndex {
4824    table: String,
4825    name: String,
4826    fields: Vec<String>,
4827}
4828
4829#[derive(Clone, Debug, Serialize, PartialEq)]
4830struct ConvexSyncChunk {
4831    operation: String,
4832    chunk: usize,
4833    count: usize,
4834    keys: Vec<String>,
4835    max_attempts: usize,
4836    retry_policy: String,
4837}
4838
4839#[derive(Clone, Debug, Serialize, PartialEq)]
4840struct ConvexTransportSummary {
4841    endpoint_env: String,
4842    endpoint_configured: bool,
4843    auth_token_env: String,
4844    auth_configured: bool,
4845    remote_snapshot: bool,
4846    applied_chunks: usize,
4847}
4848
4849#[derive(Clone, Debug, Serialize, PartialEq)]
4850struct ConvexTransportReceipt {
4851    operation: String,
4852    chunk: usize,
4853    attempt: usize,
4854    status: String,
4855    message: Option<String>,
4856}
4857
4858#[derive(Serialize)]
4859#[serde(rename_all = "camelCase")]
4860struct ConvexTransportRequest<'a> {
4861    operation: &'a str,
4862    chunk: usize,
4863    projection_version: &'a str,
4864    projection_hash: Option<&'a str>,
4865    #[serde(skip_serializing_if = "Option::is_none")]
4866    projection_meta_id: Option<&'a str>,
4867    node_rows: Vec<ConvexNodeRow>,
4868    edge_rows: Vec<ConvexEdgeRow>,
4869    keys: Vec<String>,
4870    #[serde(skip_serializing_if = "Option::is_none")]
4871    cursor: Option<String>,
4872    #[serde(skip_serializing_if = "Option::is_none")]
4873    limit: Option<usize>,
4874}
4875
4876#[derive(Deserialize)]
4877#[serde(rename_all = "camelCase")]
4878struct ConvexTransportResponse {
4879    status: Option<String>,
4880    message: Option<String>,
4881    rows: Option<ConvexProjectionRows>,
4882    #[serde(default)]
4883    meta: Option<ConvexSnapshotMeta>,
4884    #[serde(default)]
4885    page: Option<ConvexSnapshotPage>,
4886}
4887
4888#[derive(Deserialize, Debug, Clone)]
4889#[serde(rename_all = "camelCase")]
4890struct ConvexSnapshotMeta {
4891    // Captured for completeness/debugging; not currently consumed by the
4892    // freshness diff (indexes are already validated against the required set
4893    // via `convex_required_indexes`, and `page_size` is informational only).
4894    #[serde(default)]
4895    #[allow(dead_code)]
4896    indexes: Vec<ConvexRequiredIndex>,
4897    #[serde(default)]
4898    #[allow(dead_code)]
4899    node_count: Option<usize>,
4900    #[serde(default)]
4901    #[allow(dead_code)]
4902    edge_count: Option<usize>,
4903    #[serde(default)]
4904    projection_hash: Option<String>,
4905    #[serde(default)]
4906    #[allow(dead_code)]
4907    page_size: Option<usize>,
4908}
4909
4910/// Paginated snapshot page response. `rows` is either node rows or edge rows
4911/// depending on which operation was called; we deserialize as raw values to
4912/// keep the transport struct shared between both shapes, then narrow per call
4913/// site.
4914#[derive(Deserialize, Debug, Clone)]
4915#[serde(rename_all = "camelCase")]
4916struct ConvexSnapshotPage {
4917    rows: Vec<serde_json::Value>,
4918    #[serde(default)]
4919    next_cursor: Option<String>,
4920}
4921
4922#[derive(Clone, Debug, Serialize, PartialEq)]
4923struct ConvexProjectionFreshness {
4924    status: String,
4925    fail_closed: bool,
4926    local_hash: Option<String>,
4927    snapshot_hash: Option<String>,
4928    missing_nodes: Vec<String>,
4929    stale_nodes: Vec<String>,
4930    missing_edges: Vec<String>,
4931    stale_edges: Vec<String>,
4932    diagnostics: Vec<String>,
4933}
4934
4935const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4936
4937impl ConvexProjectionFreshness {
4938    fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4939        Self {
4940            status: "current".to_string(),
4941            fail_closed: false,
4942            local_hash,
4943            snapshot_hash,
4944            missing_nodes: Vec::new(),
4945            stale_nodes: Vec::new(),
4946            missing_edges: Vec::new(),
4947            stale_edges: Vec::new(),
4948            diagnostics: Vec::new(),
4949        }
4950    }
4951}
4952
4953#[derive(Clone, Debug, Serialize, PartialEq)]
4954struct ConvexSyncReport {
4955    root: String,
4956    #[serde(skip_serializing_if = "Option::is_none")]
4957    scope: Option<String>,
4958    graph_db: String,
4959    dry_run: bool,
4960    projection_version: String,
4961    projection_hash: Option<String>,
4962    required_indexes: Vec<ConvexRequiredIndex>,
4963    node_upserts: Vec<ConvexNodeRow>,
4964    edge_upserts: Vec<ConvexEdgeRow>,
4965    node_tombstones: Vec<String>,
4966    edge_tombstones: Vec<String>,
4967    chunks: Vec<ConvexSyncChunk>,
4968    freshness: ConvexProjectionFreshness,
4969    transport: Option<ConvexTransportSummary>,
4970    receipts: Vec<ConvexTransportReceipt>,
4971    diagnostics: Vec<String>,
4972    warnings: Vec<String>,
4973}
4974
4975fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4976    vec![
4977        ConvexRequiredIndex {
4978            table: "nodes".to_string(),
4979            name: "by_external_id".to_string(),
4980            fields: vec!["externalId".to_string()],
4981        },
4982        ConvexRequiredIndex {
4983            table: "nodes".to_string(),
4984            name: "by_kind".to_string(),
4985            fields: vec!["kind".to_string()],
4986        },
4987        ConvexRequiredIndex {
4988            table: "edges".to_string(),
4989            name: "by_edge_key".to_string(),
4990            fields: vec!["edgeKey".to_string()],
4991        },
4992        ConvexRequiredIndex {
4993            table: "edges".to_string(),
4994            name: "by_from_kind".to_string(),
4995            fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4996        },
4997        ConvexRequiredIndex {
4998            table: "edges".to_string(),
4999            name: "by_to_kind".to_string(),
5000            fields: vec!["toExternalId".to_string(), "kind".to_string()],
5001        },
5002    ]
5003}
5004
5005pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
5006    let content = fs::read_to_string(path)
5007        .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
5008    serde_json::from_str(&content)
5009        .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
5010}
5011
5012fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
5013    let mut diagnostics = Vec::new();
5014    let mut node_counts = BTreeMap::<&str, usize>::new();
5015    for row in &rows.nodes {
5016        *node_counts.entry(row.external_id.as_str()).or_default() += 1;
5017    }
5018    for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
5019        diagnostics.push(format!(
5020            "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
5021        ));
5022    }
5023
5024    let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
5025    let mut edge_counts = BTreeMap::<&str, usize>::new();
5026    for edge in &rows.edges {
5027        *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
5028        if !node_ids.contains(edge.from_external_id.as_str()) {
5029            diagnostics.push(format!(
5030                "Convex snapshot edge {} references missing from node {}",
5031                edge.edge_key, edge.from_external_id
5032            ));
5033        }
5034        if !node_ids.contains(edge.to_external_id.as_str()) {
5035            diagnostics.push(format!(
5036                "Convex snapshot edge {} references missing to node {}",
5037                edge.edge_key, edge.to_external_id
5038            ));
5039        }
5040        let expected_key =
5041            ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
5042        if edge.edge_key != expected_key {
5043            diagnostics.push(format!(
5044                "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
5045                edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
5046            ));
5047        }
5048    }
5049    for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
5050        diagnostics.push(format!(
5051            "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
5052        ));
5053    }
5054    diagnostics
5055}
5056
5057pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
5058    let diagnostics = convex_projection_row_diagnostics(rows);
5059    if diagnostics.is_empty() {
5060        Ok(())
5061    } else {
5062        bail!("{}", diagnostics.join("; "))
5063    }
5064}
5065
5066pub(crate) struct ConvexHttpTransport {
5067    endpoint: String,
5068    auth_token_env: String,
5069    auth_token: Option<String>,
5070}
5071
5072impl ConvexHttpTransport {
5073    fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
5074        let endpoint = endpoint
5075            .map(str::to_string)
5076            .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
5077            .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
5078        let auth_token = env::var(auth_token_env)
5079            .ok()
5080            .filter(|value| !value.trim().is_empty());
5081        Ok(Self {
5082            endpoint,
5083            auth_token_env: auth_token_env.to_string(),
5084            auth_token,
5085        })
5086    }
5087
5088    fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
5089        ConvexTransportSummary {
5090            endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
5091            endpoint_configured: true,
5092            auth_token_env: self.auth_token_env.clone(),
5093            auth_configured: self.auth_token.is_some(),
5094            remote_snapshot,
5095            applied_chunks,
5096        }
5097    }
5098
5099    fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
5100        let mut builder = ureq::post(&self.endpoint);
5101        if let Some(token) = &self.auth_token {
5102            builder = builder.header("Authorization", &format!("Bearer {token}"));
5103        }
5104        builder
5105            .send_json(request)
5106            .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
5107            .body_mut()
5108            .read_json::<ConvexTransportResponse>()
5109            .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
5110    }
5111
5112    /// Fetch a full snapshot of the Convex graph backend.
5113    ///
5114    /// Uses the paginated `snapshot_meta` + `snapshot_nodes_page` +
5115    /// `snapshot_edges_page` triplet so the call works on tables larger than
5116    /// ~5k rows (the single-shot `snapshot` query hits Convex's 15s per-request
5117    /// syscall budget at that scale; see `#convexsnapshotscale`).
5118    ///
5119    /// Falls back to the legacy single-shot `snapshot` operation if the
5120    /// backend doesn't recognize `snapshot_meta` (older deployments that
5121    /// haven't redeployed the new schema).
5122    fn fetch_snapshot(
5123        &self,
5124        projection_version: &str,
5125        scope: Option<&str>,
5126        local_hash: Option<&str>,
5127        local_rows: Option<&ConvexProjectionRows>,
5128    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5129        match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
5130            Ok(rows) => Ok(rows),
5131            Err(err) => {
5132                // Only fall through to the legacy path if the failure looks
5133                // like "operation unknown" (older backend). Any other failure
5134                // (HTTP timeout, deserialization mismatch) should surface so
5135                // the operator sees the real cause.
5136                let msg = format!("{err:#}");
5137                let is_unknown_op = msg.contains("unknown operation")
5138                    || msg.contains("snapshot_meta")
5139                    || msg.contains("404");
5140                if !is_unknown_op {
5141                    return Err(err);
5142                }
5143                self.fetch_snapshot_legacy(projection_version)
5144                    .map(|rows| (rows, Vec::new()))
5145            }
5146        }
5147    }
5148
5149    fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
5150        let response = self.post(&ConvexTransportRequest {
5151            operation: "snapshot",
5152            chunk: 0,
5153            projection_version,
5154            projection_hash: None,
5155            projection_meta_id: None,
5156            node_rows: Vec::new(),
5157            edge_rows: Vec::new(),
5158            keys: Vec::new(),
5159            cursor: None,
5160            limit: None,
5161        })?;
5162        response
5163            .rows
5164            .context("Convex snapshot response did not include rows")
5165    }
5166
5167    fn fetch_snapshot_paginated(
5168        &self,
5169        projection_version: &str,
5170        scope: Option<&str>,
5171        local_hash: Option<&str>,
5172        local_rows: Option<&ConvexProjectionRows>,
5173    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5174        let projection_meta_id = graph_projection_meta_id(scope);
5175        let meta_response = self.post(&ConvexTransportRequest {
5176            operation: "snapshot_meta",
5177            chunk: 0,
5178            projection_version,
5179            projection_hash: None,
5180            projection_meta_id: Some(&projection_meta_id),
5181            node_rows: Vec::new(),
5182            edge_rows: Vec::new(),
5183            keys: Vec::new(),
5184            cursor: None,
5185            limit: None,
5186        })?;
5187        if matches!(meta_response.status.as_deref(), Some("error")) {
5188            anyhow::bail!(
5189                "Convex snapshot_meta returned error: {}",
5190                meta_response.message.unwrap_or_default()
5191            );
5192        }
5193        let meta = meta_response
5194            .meta
5195            .context("Convex snapshot_meta response did not include meta")?;
5196        if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
5197            (meta.projection_hash.as_deref(), local_hash, local_rows)
5198            && remote_hash == local_hash
5199        {
5200            return Ok((
5201                local_rows.clone(),
5202                vec![
5203                    "remote projection hash matched local graph; skipped full row-page snapshot diff"
5204                        .to_string(),
5205                ],
5206            ));
5207        }
5208
5209        let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
5210        let mut node_cursor: Option<String> = None;
5211        loop {
5212            let response = self.post(&ConvexTransportRequest {
5213                operation: "snapshot_nodes_page",
5214                chunk: 0,
5215                projection_version,
5216                projection_hash: None,
5217                projection_meta_id: None,
5218                node_rows: Vec::new(),
5219                edge_rows: Vec::new(),
5220                keys: Vec::new(),
5221                cursor: node_cursor.clone(),
5222                limit: None,
5223            })?;
5224            let page = response
5225                .page
5226                .context("Convex snapshot_nodes_page response did not include page")?;
5227            for raw in page.rows {
5228                let row: ConvexNodeRow =
5229                    serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
5230                nodes.push(row);
5231            }
5232            match page.next_cursor {
5233                Some(next) => node_cursor = Some(next),
5234                None => break,
5235            }
5236        }
5237
5238        let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
5239        let mut edge_cursor: Option<String> = None;
5240        loop {
5241            let response = self.post(&ConvexTransportRequest {
5242                operation: "snapshot_edges_page",
5243                chunk: 0,
5244                projection_version,
5245                projection_hash: None,
5246                projection_meta_id: None,
5247                node_rows: Vec::new(),
5248                edge_rows: Vec::new(),
5249                keys: Vec::new(),
5250                cursor: edge_cursor.clone(),
5251                limit: None,
5252            })?;
5253            let page = response
5254                .page
5255                .context("Convex snapshot_edges_page response did not include page")?;
5256            for raw in page.rows {
5257                let row: ConvexEdgeRow =
5258                    serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
5259                edges.push(row);
5260            }
5261            match page.next_cursor {
5262                Some(next) => edge_cursor = Some(next),
5263                None => break,
5264            }
5265        }
5266
5267        Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
5268    }
5269
5270    fn apply_chunk(
5271        &self,
5272        report: &ConvexSyncReport,
5273        chunk: &ConvexSyncChunk,
5274    ) -> Result<ConvexTransportReceipt> {
5275        let node_rows = if chunk.operation == "upsert_nodes" {
5276            report
5277                .node_upserts
5278                .iter()
5279                .filter(|row| chunk.keys.contains(&row.external_id))
5280                .cloned()
5281                .collect()
5282        } else {
5283            Vec::new()
5284        };
5285        let edge_rows = if chunk.operation == "upsert_edges" {
5286            report
5287                .edge_upserts
5288                .iter()
5289                .filter(|row| chunk.keys.contains(&row.edge_key))
5290                .cloned()
5291                .collect()
5292        } else {
5293            Vec::new()
5294        };
5295        let request = ConvexTransportRequest {
5296            operation: &chunk.operation,
5297            chunk: chunk.chunk,
5298            projection_version: &report.projection_version,
5299            projection_hash: report.projection_hash.as_deref(),
5300            projection_meta_id: None,
5301            node_rows,
5302            edge_rows,
5303            keys: chunk.keys.clone(),
5304            cursor: None,
5305            limit: None,
5306        };
5307        let mut last_error = None;
5308        for attempt in 1..=chunk.max_attempts {
5309            match self.post(&request) {
5310                Ok(response) => {
5311                    return Ok(ConvexTransportReceipt {
5312                        operation: chunk.operation.clone(),
5313                        chunk: chunk.chunk,
5314                        attempt,
5315                        status: response.status.unwrap_or_else(|| "ok".to_string()),
5316                        message: response.message,
5317                    });
5318                }
5319                Err(err) => {
5320                    last_error = Some(err);
5321                    if attempt < chunk.max_attempts {
5322                        std::thread::sleep(Duration::from_millis(100 * attempt as u64));
5323                    }
5324                }
5325            }
5326        }
5327        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
5328            .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
5329    }
5330}
5331
5332fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
5333    let meta_id = graph_projection_meta_id(scope);
5334    rows.nodes
5335        .iter()
5336        .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
5337        .and_then(|row| row.properties.get("content_hash").cloned())
5338}
5339
5340fn convex_projection_freshness(
5341    local: &ConvexProjectionRows,
5342    snapshot: Option<&ConvexProjectionRows>,
5343    scope: Option<&str>,
5344) -> ConvexProjectionFreshness {
5345    let local_hash = convex_projection_hash(local, scope);
5346    let Some(snapshot) = snapshot else {
5347        return ConvexProjectionFreshness {
5348            status: "unchecked".to_string(),
5349            fail_closed: false,
5350            local_hash,
5351            snapshot_hash: None,
5352            missing_nodes: Vec::new(),
5353            stale_nodes: Vec::new(),
5354            missing_edges: Vec::new(),
5355            stale_edges: Vec::new(),
5356            diagnostics: vec![
5357                "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
5358            ],
5359        };
5360    };
5361
5362    let snapshot_hash = convex_projection_hash(snapshot, scope);
5363    let snapshot_nodes = snapshot
5364        .nodes
5365        .iter()
5366        .map(|row| (row.external_id.as_str(), row))
5367        .collect::<BTreeMap<_, _>>();
5368    let snapshot_edges = snapshot
5369        .edges
5370        .iter()
5371        .map(|row| (row.edge_key.as_str(), row))
5372        .collect::<BTreeMap<_, _>>();
5373
5374    let mut missing_nodes = Vec::new();
5375    let mut stale_nodes = Vec::new();
5376    for row in &local.nodes {
5377        match snapshot_nodes.get(row.external_id.as_str()) {
5378            Some(snapshot_row) if *snapshot_row == row => {}
5379            Some(_) => stale_nodes.push(row.external_id.clone()),
5380            None => missing_nodes.push(row.external_id.clone()),
5381        }
5382    }
5383
5384    let mut missing_edges = Vec::new();
5385    let mut stale_edges = Vec::new();
5386    for row in &local.edges {
5387        match snapshot_edges.get(row.edge_key.as_str()) {
5388            Some(snapshot_row) if *snapshot_row == row => {}
5389            Some(_) => stale_edges.push(row.edge_key.clone()),
5390            None => missing_edges.push(row.edge_key.clone()),
5391        }
5392    }
5393
5394    let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
5395    let rows_current = missing_nodes.is_empty()
5396        && stale_nodes.is_empty()
5397        && missing_edges.is_empty()
5398        && stale_edges.is_empty();
5399    if hash_current && rows_current {
5400        return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
5401    }
5402
5403    let mut diagnostics = Vec::new();
5404    if local_hash != snapshot_hash {
5405        diagnostics.push(format!(
5406            "projection hash mismatch: local={} snapshot={}",
5407            local_hash.as_deref().unwrap_or("missing"),
5408            snapshot_hash.as_deref().unwrap_or("missing")
5409        ));
5410    }
5411    if !missing_nodes.is_empty() || !missing_edges.is_empty() {
5412        diagnostics.push(format!(
5413            "Convex snapshot is missing {} node(s) and {} edge(s)",
5414            missing_nodes.len(),
5415            missing_edges.len()
5416        ));
5417    }
5418    if !stale_nodes.is_empty() || !stale_edges.is_empty() {
5419        diagnostics.push(format!(
5420            "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
5421            stale_nodes.len(),
5422            stale_edges.len()
5423        ));
5424    }
5425
5426    ConvexProjectionFreshness {
5427        status: "stale".to_string(),
5428        fail_closed: true,
5429        local_hash,
5430        snapshot_hash,
5431        missing_nodes,
5432        stale_nodes,
5433        missing_edges,
5434        stale_edges,
5435        diagnostics,
5436    }
5437}
5438
5439pub(crate) fn verify_convex_projection_snapshot(
5440    root: &Path,
5441    scope: Option<&str>,
5442    snapshot_path: &Path,
5443) -> Result<()> {
5444    let graph_db = graph_substrate_db_path(root, scope);
5445    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5446    let local = convex_rows_from_graph_store(&store)?;
5447    let snapshot = load_convex_projection_rows(snapshot_path)?;
5448    validate_convex_projection_rows(&snapshot)?;
5449    let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
5450    if freshness.fail_closed {
5451        bail!(
5452            "Convex graph projection is not current for {}: {}",
5453            root.display(),
5454            freshness.diagnostics.join("; ")
5455        );
5456    }
5457    Ok(())
5458}
5459
5460fn convex_rows_diff(
5461    local: &ConvexProjectionRows,
5462    snapshot: Option<&ConvexProjectionRows>,
5463) -> (
5464    Vec<ConvexNodeRow>,
5465    Vec<ConvexEdgeRow>,
5466    Vec<String>,
5467    Vec<String>,
5468) {
5469    let Some(snapshot) = snapshot else {
5470        return (
5471            local.nodes.clone(),
5472            local.edges.clone(),
5473            Vec::new(),
5474            Vec::new(),
5475        );
5476    };
5477    let local_nodes = local
5478        .nodes
5479        .iter()
5480        .map(|row| (row.external_id.as_str(), row))
5481        .collect::<BTreeMap<_, _>>();
5482    let local_edges = local
5483        .edges
5484        .iter()
5485        .map(|row| (row.edge_key.as_str(), row))
5486        .collect::<BTreeMap<_, _>>();
5487    let snapshot_nodes = snapshot
5488        .nodes
5489        .iter()
5490        .map(|row| (row.external_id.as_str(), row))
5491        .collect::<BTreeMap<_, _>>();
5492    let snapshot_edges = snapshot
5493        .edges
5494        .iter()
5495        .map(|row| (row.edge_key.as_str(), row))
5496        .collect::<BTreeMap<_, _>>();
5497
5498    let node_upserts = local
5499        .nodes
5500        .iter()
5501        .filter(|row| {
5502            snapshot_nodes
5503                .get(row.external_id.as_str())
5504                .is_none_or(|snapshot_row| *snapshot_row != *row)
5505        })
5506        .cloned()
5507        .collect::<Vec<_>>();
5508    let edge_upserts = local
5509        .edges
5510        .iter()
5511        .filter(|row| {
5512            snapshot_edges
5513                .get(row.edge_key.as_str())
5514                .is_none_or(|snapshot_row| *snapshot_row != *row)
5515        })
5516        .cloned()
5517        .collect::<Vec<_>>();
5518    let node_tombstones = snapshot
5519        .nodes
5520        .iter()
5521        .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
5522        .map(|row| row.external_id.clone())
5523        .collect::<Vec<_>>();
5524    let edge_tombstones = snapshot
5525        .edges
5526        .iter()
5527        .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
5528        .map(|row| row.edge_key.clone())
5529        .collect::<Vec<_>>();
5530
5531    (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
5532}
5533
5534fn push_sync_chunks(
5535    chunks: &mut Vec<ConvexSyncChunk>,
5536    operation: &str,
5537    keys: Vec<String>,
5538    size: usize,
5539) {
5540    if keys.is_empty() {
5541        return;
5542    }
5543    for (idx, chunk) in keys.chunks(size).enumerate() {
5544        chunks.push(ConvexSyncChunk {
5545            operation: operation.to_string(),
5546            chunk: idx + 1,
5547            count: chunk.len(),
5548            keys: chunk.to_vec(),
5549            max_attempts: 3,
5550            retry_policy:
5551                "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
5552                    .to_string(),
5553        });
5554    }
5555}
5556
5557pub(crate) fn build_convex_sync_report_with_snapshot(
5558    path: &Path,
5559    scope: Option<&str>,
5560    snapshot: Option<ConvexProjectionRows>,
5561    chunk_size: usize,
5562    dry_run: bool,
5563) -> Result<ConvexSyncReport> {
5564    if chunk_size == 0 {
5565        bail!("--chunk-size must be greater than zero");
5566    }
5567    let root = lint::resolve_project_root_or_canonical_path(path)?;
5568    let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
5569    let graph_db = graph_substrate_db_path(&root, scope);
5570    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5571    let local = convex_rows_from_graph_store(&store)?;
5572    let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
5573    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
5574        convex_rows_diff(&local, snapshot.as_ref());
5575
5576    let mut chunks = Vec::new();
5577    push_sync_chunks(
5578        &mut chunks,
5579        "delete_edges",
5580        edge_tombstones.clone(),
5581        chunk_size,
5582    );
5583    push_sync_chunks(
5584        &mut chunks,
5585        "upsert_nodes",
5586        node_upserts
5587            .iter()
5588            .map(|row| row.external_id.clone())
5589            .collect(),
5590        chunk_size,
5591    );
5592    push_sync_chunks(
5593        &mut chunks,
5594        "upsert_edges",
5595        edge_upserts
5596            .iter()
5597            .map(|row| row.edge_key.clone())
5598            .collect(),
5599        chunk_size,
5600    );
5601    push_sync_chunks(
5602        &mut chunks,
5603        "delete_nodes",
5604        node_tombstones.clone(),
5605        chunk_size,
5606    );
5607
5608    let mut diagnostics = vec![
5609        "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5610            .to_string(),
5611    ];
5612    if dry_run {
5613        diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5614    }
5615    if freshness.fail_closed {
5616        diagnostics.push(
5617            "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5618                .to_string(),
5619        );
5620    }
5621
5622    Ok(ConvexSyncReport {
5623        root: root.to_string_lossy().to_string(),
5624        scope: scope.map(str::to_string),
5625        graph_db: graph_db.to_string_lossy().to_string(),
5626        dry_run,
5627        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5628        projection_hash: convex_projection_hash(&local, scope),
5629        required_indexes: convex_required_indexes(),
5630        node_upserts,
5631        edge_upserts,
5632        node_tombstones,
5633        edge_tombstones,
5634        chunks,
5635        freshness,
5636        transport: None,
5637        receipts: Vec::new(),
5638        diagnostics,
5639        warnings: graph.warnings,
5640    })
5641}
5642
5643#[cfg(test)]
5644fn build_convex_sync_report(
5645    path: &Path,
5646    scope: Option<&str>,
5647    snapshot_path: Option<&Path>,
5648    chunk_size: usize,
5649) -> Result<ConvexSyncReport> {
5650    let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5651    build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5652}
5653
5654pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5655    if compact {
5656        println!(
5657            "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5658            report.node_upserts.len(),
5659            report.node_tombstones.len(),
5660            report.edge_upserts.len(),
5661            report.edge_tombstones.len(),
5662            report.chunks.len(),
5663            report.freshness.status
5664        );
5665        return;
5666    }
5667
5668    println!(
5669        "Convex graph sync {}",
5670        if report.dry_run { "dry-run" } else { "apply" }
5671    );
5672    println!("root: {}", report.root);
5673    println!("graph_db: {}", report.graph_db);
5674    println!(
5675        "upserts: {} node(s), {} edge(s)",
5676        report.node_upserts.len(),
5677        report.edge_upserts.len()
5678    );
5679    println!(
5680        "tombstones: {} node(s), {} edge(s)",
5681        report.node_tombstones.len(),
5682        report.edge_tombstones.len()
5683    );
5684    println!("chunks: {}", report.chunks.len());
5685    println!("freshness: {}", report.freshness.status);
5686    if let Some(transport) = &report.transport {
5687        println!(
5688            "transport: endpoint_env={} auth_env={} applied_chunks={}",
5689            transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5690        );
5691    }
5692    for receipt in &report.receipts {
5693        println!(
5694            "receipt: {} chunk {} attempt {} {}",
5695            receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5696        );
5697    }
5698    for diagnostic in report
5699        .diagnostics
5700        .iter()
5701        .chain(report.freshness.diagnostics.iter())
5702    {
5703        println!("- {}", diagnostic);
5704    }
5705}
5706
5707pub(crate) struct ConvexSyncOptions<'a> {
5708    path: &'a Path,
5709    scope: Option<&'a str>,
5710    snapshot: Option<&'a Path>,
5711    chunk_size: usize,
5712    remote_snapshot: bool,
5713    apply: bool,
5714    endpoint: Option<&'a str>,
5715    auth_token_env: &'a str,
5716}
5717
5718#[derive(Serialize)]
5719struct GraphDbSchemaField {
5720    name: &'static str,
5721    value_type: &'static str,
5722    description: &'static str,
5723}
5724
5725#[derive(Serialize)]
5726struct GraphDbSchemaOperation {
5727    command: &'static str,
5728    description: &'static str,
5729}
5730
5731#[derive(Serialize)]
5732struct GraphDbSchemaContract {
5733    name: &'static str,
5734    version: &'static str,
5735    description: &'static str,
5736}
5737
5738#[derive(Serialize)]
5739struct GraphDbSchema {
5740    contract_versions: Vec<GraphDbSchemaContract>,
5741    node_fields: Vec<GraphDbSchemaField>,
5742    edge_fields: Vec<GraphDbSchemaField>,
5743    operations: Vec<GraphDbSchemaOperation>,
5744}
5745
5746#[derive(Clone, Serialize, Deserialize)]
5747struct GraphDbFreshnessReport {
5748    status: String,
5749    fail_closed: bool,
5750    projection_version: Option<String>,
5751    content_hash: Option<String>,
5752    source_watermark: Option<String>,
5753    diagnostics: Vec<String>,
5754}
5755
5756#[derive(Clone, Debug, Serialize)]
5757pub(crate) struct GraphEffectivenessReadiness {
5758    pub(crate) status: String,
5759    pub(crate) fail_closed: bool,
5760    pub(crate) reason: String,
5761    pub(crate) diagnostics: Vec<String>,
5762    pub(crate) next_commands: Vec<String>,
5763}
5764
5765#[derive(Clone, Debug, Serialize, PartialEq)]
5766struct GraphDbPropertyFilter {
5767    key: String,
5768    value: String,
5769}
5770
5771#[derive(Clone, Debug, Default)]
5772struct GraphDbQueryOptions {
5773    cursor: Option<String>,
5774    limit: Option<usize>,
5775    property_filters: Vec<GraphDbPropertyFilter>,
5776}
5777
5778#[derive(Clone, Debug, Serialize, PartialEq)]
5779struct GraphDbPageReport {
5780    #[serde(skip_serializing_if = "Option::is_none")]
5781    cursor: Option<String>,
5782    #[serde(skip_serializing_if = "Option::is_none")]
5783    limit: Option<usize>,
5784    #[serde(skip_serializing_if = "Option::is_none")]
5785    next_cursor: Option<String>,
5786    returned_nodes: usize,
5787    returned_edges: usize,
5788    truncated: bool,
5789    property_filters: Vec<GraphDbPropertyFilter>,
5790    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5791    diagnostics: Vec<String>,
5792}
5793
5794type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5795
5796#[derive(Clone, Debug, Serialize)]
5797struct CommunityTruncationSummary {
5798    total_communities: usize,
5799    fully_kept: usize,
5800    partially_pruned: usize,
5801    fully_pruned: usize,
5802    pruned_community_kinds: Vec<String>,
5803    pruned_community_top_labels: Vec<String>,
5804}
5805
5806#[derive(Clone, Debug, Serialize)]
5807struct GraphDbRankedNeighborhoodComparison {
5808    traversal_nodes: usize,
5809    traversal_edges: usize,
5810    pruned_count: usize,
5811    total_discovered: usize,
5812    latency_micros: u128,
5813    overlap_with_unranked_pct: f64,
5814    useful_hit_density_ranked: f64,
5815    useful_hit_density_unranked: f64,
5816    duplicate_name_count_ranked: usize,
5817    duplicate_name_count_unranked: usize,
5818    handle_coverage_ranked_pct: f64,
5819    handle_coverage_unranked_pct: f64,
5820    #[serde(skip_serializing_if = "Option::is_none")]
5821    community_truncation_summary: Option<CommunityTruncationSummary>,
5822    diagnostics: Vec<String>,
5823}
5824
5825#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5826struct GraphDbDroppedByBudget {
5827    item: String,
5828    kind: String,
5829    dropped: usize,
5830    reason: String,
5831}
5832
5833#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5834struct GraphDbOutputBudgetReport {
5835    max_tokens: usize,
5836    estimated_tokens: usize,
5837    selected_nodes: usize,
5838    selected_edges: usize,
5839    candidate_nodes: usize,
5840    candidate_edges: usize,
5841    dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5842    diagnostics: Vec<String>,
5843}
5844
5845#[derive(Clone, Debug, Serialize, PartialEq)]
5846struct GraphDbKnowledgeRetrieval {
5847    mode: String,
5848    query: String,
5849    seed_kind: String,
5850    seed_limit: usize,
5851    seed_count: usize,
5852    depth: usize,
5853    limit: usize,
5854    node_count: usize,
5855    edge_count: usize,
5856    truncated: bool,
5857    traversal: String,
5858    freshness_boundary: String,
5859    privacy_boundary: String,
5860    diagnostics: Vec<String>,
5861}
5862
5863struct GraphDbSemanticSeededSubgraph {
5864    nodes: Vec<SubstrateGraphNode>,
5865    edges: Vec<SubstrateGraphEdge>,
5866    truncated: bool,
5867    diagnostics: Vec<String>,
5868}
5869
5870type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5871
5872#[derive(Serialize)]
5873struct GraphDbReport {
5874    root: String,
5875    #[serde(skip_serializing_if = "Option::is_none")]
5876    scope: Option<String>,
5877    backend: String,
5878    query: String,
5879    freshness: GraphDbFreshnessReport,
5880    #[serde(skip_serializing_if = "Option::is_none")]
5881    readiness: Option<GraphEffectivenessReadiness>,
5882    #[serde(skip_serializing_if = "Option::is_none")]
5883    schema: Option<GraphDbSchema>,
5884    #[serde(skip_serializing_if = "Option::is_none")]
5885    node: Option<SubstrateTerseGraphNode>,
5886    #[serde(skip_serializing_if = "Option::is_none")]
5887    edge: Option<SubstrateTerseGraphEdge>,
5888    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5889    nodes: Vec<SubstrateTerseGraphNode>,
5890    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5891    edges: Vec<SubstrateTerseGraphEdge>,
5892    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5893    ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5894    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5895    semantic_related: Vec<SemanticRelatedItem>,
5896    #[serde(skip_serializing_if = "Option::is_none")]
5897    neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5898    #[serde(skip_serializing_if = "Option::is_none")]
5899    ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5900    #[serde(skip_serializing_if = "Option::is_none")]
5901    knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5902    #[serde(skip_serializing_if = "Option::is_none")]
5903    output_budget: Option<GraphDbOutputBudgetReport>,
5904    #[serde(skip_serializing_if = "Option::is_none")]
5905    path: Option<substrate::GraphPath>,
5906    #[serde(skip_serializing_if = "Option::is_none")]
5907    page: Option<GraphDbPageReport>,
5908    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5909    warnings: Vec<String>,
5910}
5911
5912struct ExperimentalReadOnlyGraphStore {
5913    backend: GraphDbExperimentalBackend,
5914    nodes: BTreeMap<String, SubstrateGraphNode>,
5915    edges: BTreeMap<String, SubstrateGraphEdge>,
5916    node_ids_by_kind: BTreeMap<String, Vec<String>>,
5917    outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5918}
5919
5920impl ExperimentalReadOnlyGraphStore {
5921    fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5922        validate_convex_projection_rows(rows)?;
5923        let nodes = rows
5924            .nodes
5925            .iter()
5926            .map(|row| {
5927                let node = SubstrateGraphNode {
5928                    id: row.external_id.clone(),
5929                    kind: row.kind.clone(),
5930                    label: row.label.clone(),
5931                    properties: row.properties.clone(),
5932                    provenance: row.provenance.clone(),
5933                    freshness: row.freshness.clone(),
5934                };
5935                (node.id.clone(), node)
5936            })
5937            .collect::<BTreeMap<_, _>>();
5938        let edges = rows
5939            .edges
5940            .iter()
5941            .map(|row| {
5942                let edge = SubstrateGraphEdge {
5943                    id: row.edge_key.clone(),
5944                    from_id: row.from_external_id.clone(),
5945                    to_id: row.to_external_id.clone(),
5946                    kind: row.kind.clone(),
5947                    properties: row.properties.clone(),
5948                    provenance: row.provenance.clone(),
5949                    freshness: row.freshness.clone(),
5950                };
5951                (graph_db_edge_key(&edge), edge)
5952            })
5953            .collect::<BTreeMap<_, _>>();
5954        let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5955        for node in nodes.values() {
5956            node_ids_by_kind
5957                .entry(node.kind.clone())
5958                .or_default()
5959                .push(node.id.clone());
5960        }
5961        for ids in node_ids_by_kind.values_mut() {
5962            ids.sort();
5963        }
5964        let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5965        for edge in edges.values() {
5966            outgoing_edge_keys_by_from
5967                .entry(edge.from_id.clone())
5968                .or_default()
5969                .push(graph_db_edge_key(edge));
5970        }
5971        for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5972            edge_keys.sort_by(|left_key, right_key| {
5973                let left = &edges[left_key];
5974                let right = &edges[right_key];
5975                left.to_id
5976                    .cmp(&right.to_id)
5977                    .then(left.kind.cmp(&right.kind))
5978                    .then(left_key.cmp(right_key))
5979            });
5980        }
5981        Ok(Self {
5982            backend,
5983            nodes,
5984            edges,
5985            node_ids_by_kind,
5986            outgoing_edge_keys_by_from,
5987        })
5988    }
5989}
5990
5991impl GraphStore for ExperimentalReadOnlyGraphStore {
5992    fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5993        bail!("{} backend-eval adapter is read-only", self.backend.name())
5994    }
5995
5996    fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5997        bail!("{} backend-eval adapter is read-only", self.backend.name())
5998    }
5999
6000    fn delete_node(&self, _id: &str) -> Result<usize> {
6001        bail!("{} backend-eval adapter is read-only", self.backend.name())
6002    }
6003
6004    fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
6005        bail!("{} backend-eval adapter is read-only", self.backend.name())
6006    }
6007
6008    fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
6009        Ok(self.nodes.get(id).cloned())
6010    }
6011
6012    fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
6013        Ok(self.nodes.values().cloned().collect())
6014    }
6015
6016    fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
6017        let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
6018        edges.sort_by(|left, right| {
6019            left.from_id
6020                .cmp(&right.from_id)
6021                .then(left.kind.cmp(&right.kind))
6022                .then(left.to_id.cmp(&right.to_id))
6023        });
6024        Ok(edges)
6025    }
6026
6027    fn graph_counts(&self) -> Result<(usize, usize)> {
6028        Ok((self.nodes.len(), self.edges.len()))
6029    }
6030
6031    fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
6032        let mut edges = self
6033            .edges
6034            .values()
6035            .filter(|edge| edge.from_id != edge.to_id)
6036            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6037            .cloned()
6038            .collect::<Vec<_>>();
6039        edges.sort_by(|left, right| {
6040            left.from_id
6041                .cmp(&right.from_id)
6042                .then(left.kind.cmp(&right.kind))
6043                .then(left.to_id.cmp(&right.to_id))
6044        });
6045        Ok(edges.into_iter().next())
6046    }
6047
6048    fn sample_edge_with_property(
6049        &self,
6050    ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
6051        Ok(self
6052            .edges
6053            .values()
6054            .filter(|edge| edge.from_id != edge.to_id)
6055            .filter_map(|edge| {
6056                edge.properties.iter().next().map(|(key, value)| {
6057                    (
6058                        edge,
6059                        GraphPropertyFilter {
6060                            key: key.clone(),
6061                            value: value.clone(),
6062                        },
6063                    )
6064                })
6065            })
6066            .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
6067                left_filter
6068                    .key
6069                    .cmp(&right_filter.key)
6070                    .then(left_filter.value.cmp(&right_filter.value))
6071                    .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
6072            })
6073            .map(|(edge, filter)| (edge.clone(), filter)))
6074    }
6075
6076    fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
6077        Ok(self
6078            .node_ids_by_kind
6079            .get(kind)
6080            .into_iter()
6081            .flatten()
6082            .filter_map(|id| self.nodes.get(id).cloned())
6083            .collect())
6084    }
6085
6086    fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
6087        Ok(self
6088            .outgoing_edge_keys_by_from
6089            .get(from_id)
6090            .into_iter()
6091            .flatten()
6092            .filter_map(|key| self.edges.get(key))
6093            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6094            .cloned()
6095            .collect())
6096    }
6097
6098    fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
6099        Ok(self
6100            .edges
6101            .values()
6102            .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
6103            .cloned()
6104            .collect())
6105    }
6106
6107    fn shortest_path(
6108        &self,
6109        from_id: &str,
6110        to_id: &str,
6111        kind: Option<&str>,
6112    ) -> Result<Option<substrate::GraphPath>> {
6113        if from_id == to_id {
6114            return Ok(Some(substrate::GraphPath {
6115                nodes: vec![from_id.to_string()],
6116                hops: 0,
6117            }));
6118        }
6119
6120        let mut queue = VecDeque::new();
6121        let mut parent = BTreeMap::<String, String>::new();
6122        parent.insert(from_id.to_string(), String::new());
6123        queue.push_back(from_id.to_string());
6124
6125        while let Some(current) = queue.pop_front() {
6126            for edge in self.outgoing_edges(&current, kind)? {
6127                if parent.contains_key(&edge.to_id) {
6128                    continue;
6129                }
6130                parent.insert(edge.to_id.clone(), current.clone());
6131                if edge.to_id == to_id {
6132                    let mut nodes = vec![to_id.to_string()];
6133                    let mut cursor = to_id;
6134                    while let Some(previous) = parent.get(cursor) {
6135                        if previous.is_empty() {
6136                            break;
6137                        }
6138                        nodes.push(previous.clone());
6139                        cursor = previous;
6140                    }
6141                    nodes.reverse();
6142                    return Ok(Some(substrate::GraphPath {
6143                        hops: nodes.len().saturating_sub(1),
6144                        nodes,
6145                    }));
6146                }
6147                queue.push_back(edge.to_id);
6148            }
6149        }
6150
6151        Ok(None)
6152    }
6153
6154    fn reachable_nodes_by_kinds(
6155        &self,
6156        from_id: &str,
6157        kinds: &[&str],
6158        depth: usize,
6159        limit: usize,
6160    ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
6161        let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
6162        let mut rows = requested
6163            .iter()
6164            .map(|kind| {
6165                (
6166                    (*kind).to_string(),
6167                    BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
6168                )
6169            })
6170            .collect::<BTreeMap<_, _>>();
6171        if requested.is_empty() {
6172            return Ok(BTreeMap::new());
6173        }
6174
6175        let mut seen = BTreeSet::from([from_id.to_string()]);
6176        let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
6177        while let Some((current, path)) = queue.pop_front() {
6178            let current_depth = path.len().saturating_sub(1);
6179            if current_depth >= depth {
6180                continue;
6181            }
6182            for edge in self.outgoing_edges(&current, None)? {
6183                if !seen.insert(edge.to_id.clone()) {
6184                    continue;
6185                }
6186                let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
6187                    continue;
6188                };
6189                let mut next_path = path.clone();
6190                next_path.push(edge.to_id.clone());
6191                let graph_path = substrate::GraphPath {
6192                    hops: next_path.len().saturating_sub(1),
6193                    nodes: next_path.clone(),
6194                };
6195                if requested.contains(node.kind.as_str()) {
6196                    rows.entry(node.kind.clone())
6197                        .or_default()
6198                        .entry(node.id.clone())
6199                        .or_insert((node.clone(), graph_path));
6200                }
6201                queue.push_back((edge.to_id, next_path));
6202            }
6203        }
6204
6205        Ok(rows
6206            .into_iter()
6207            .map(|(kind, values)| {
6208                let mut values = values.into_values().collect::<Vec<_>>();
6209                values.sort_by(|(left_node, left_path), (right_node, right_path)| {
6210                    left_path
6211                        .hops
6212                        .cmp(&right_path.hops)
6213                        .then(left_node.label.cmp(&right_node.label))
6214                        .then(left_node.id.cmp(&right_node.id))
6215                });
6216                if limit > 0 && values.len() > limit {
6217                    values.truncate(limit);
6218                }
6219                (kind, values)
6220            })
6221            .collect())
6222    }
6223}
6224
6225pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
6226pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
6227pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
6228const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
6229pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
6230const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
6231const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
6232const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
6233const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
6234
6235#[derive(Clone, Serialize, Deserialize)]
6236pub(crate) struct GraphDbBackendEvalPhaseTiming {
6237    name: String,
6238    duration_micros: u128,
6239    detail: String,
6240}
6241
6242#[derive(Serialize, Deserialize)]
6243struct GraphDbBackendEvalFullProjectionCache {
6244    version: String,
6245    key: String,
6246    source_watermark: String,
6247    projection: GraphProjection,
6248    warnings: Vec<String>,
6249}
6250
6251#[derive(Clone, Default)]
6252struct GraphDbBackendEvalFullProjectionCacheStats {
6253    hit: bool,
6254    disk_bytes: u64,
6255    json_bytes: u64,
6256    pruned_files: usize,
6257    pruned_bytes: u64,
6258}
6259
6260#[derive(Serialize)]
6261struct GraphDbBackendEvalRawSourceWatermarkRow {
6262    path: String,
6263    bytes: u64,
6264    content_hash: String,
6265}
6266
6267#[derive(Clone)]
6268struct GraphDbBackendEvalFullProjectionSourceWatermark {
6269    value: String,
6270    detail: String,
6271}
6272
6273#[derive(Serialize)]
6274pub(crate) struct GraphDbBackendEvalConfig {
6275    high_degree_nodes: usize,
6276    high_degree_fanout: usize,
6277    deep_chain_nodes: usize,
6278    deep_chain_fanout: usize,
6279    depth: usize,
6280    limit: usize,
6281    impact_limit: usize,
6282    path_max_hops: usize,
6283    path_direct_hop_budget: usize,
6284    path_deep_chain_hop_budget: usize,
6285    path_extended_hop_budgets: Vec<usize>,
6286    path_hop_policy: String,
6287    path_probe_strategy: String,
6288    path_query_plan_checks: Vec<String>,
6289    full_projection_enabled: bool,
6290    full_projection_profile: String,
6291    normalization_row_unit: usize,
6292}
6293
6294#[derive(Clone)]
6295struct GraphDbBackendEvalSignature {
6296    operation: String,
6297    value: serde_json::Value,
6298}
6299
6300#[derive(Serialize)]
6301struct GraphDbBackendEvalOperation {
6302    name: String,
6303    supported: bool,
6304    status: String,
6305    duration_micros: u128,
6306    #[serde(skip_serializing_if = "Option::is_none")]
6307    rows: Option<usize>,
6308    #[serde(skip_serializing_if = "Option::is_none")]
6309    error: Option<String>,
6310}
6311
6312#[derive(Serialize)]
6313struct GraphDbBackendEvalParity {
6314    matches_sqlite: bool,
6315    diagnostics: Vec<String>,
6316}
6317
6318#[derive(Serialize)]
6319struct GraphDbBackendEvalBackendReport {
6320    backend: String,
6321    adapter: String,
6322    read_only: bool,
6323    projection_load: String,
6324    operations: Vec<GraphDbBackendEvalOperation>,
6325    total_micros: u128,
6326    parity: GraphDbBackendEvalParity,
6327    lock_behavior: String,
6328    install_portability: String,
6329}
6330
6331#[derive(Serialize)]
6332struct GraphDbBackendEvalDataset {
6333    name: String,
6334    target_count: usize,
6335    nodes: usize,
6336    edges: usize,
6337    backends: Vec<GraphDbBackendEvalBackendReport>,
6338}
6339
6340#[derive(Serialize)]
6341struct GraphDbBackendPromotionDecision {
6342    backend: String,
6343    decision: String,
6344    reasons: Vec<String>,
6345    gate: GraphDbBackendPromotionGate,
6346}
6347
6348#[derive(Serialize)]
6349struct GraphDbBackendEvalPerformanceGate {
6350    baseline_fixture: String,
6351    ci_profile: String,
6352    opt_in_real_profile: String,
6353    full_projection_cache_hit_gate: String,
6354    allowed_regression_percent: f64,
6355    minimum_sample_runs: usize,
6356    normalized_metric_unit: String,
6357    required_metrics: Vec<String>,
6358    digest_command: String,
6359    repeated_sample_command: String,
6360    hop_cap_promotion: GraphDbHopCapPromotionGate,
6361    backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
6362}
6363
6364#[derive(Serialize)]
6365struct GraphDbHopCapPromotionGate {
6366    status: String,
6367    current_default_hops: usize,
6368    candidate_hop_tiers: Vec<usize>,
6369    required_backend: String,
6370    required_workloads: Vec<String>,
6371    required_metrics: Vec<String>,
6372    allowed_regression_percent: f64,
6373    minimum_sample_runs: usize,
6374    decision_rule: String,
6375}
6376
6377#[derive(Serialize)]
6378struct GraphDbBackendAdapterSpikeGate {
6379    status: String,
6380    candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
6381    required_workloads: Vec<String>,
6382    required_checks: Vec<String>,
6383    decision_rule: String,
6384    evidence_plan: String,
6385}
6386
6387#[derive(Serialize)]
6388struct GraphDbBackendAdapterSpikeCandidate {
6389    backend: String,
6390    adapter_label: String,
6391    projection_load: String,
6392    lock_behavior: String,
6393    install_portability: String,
6394}
6395
6396#[derive(Serialize)]
6397pub(crate) struct GraphDbBackendEvalReport {
6398    root: String,
6399    #[serde(skip_serializing_if = "Option::is_none")]
6400    scope: Option<String>,
6401    label: String,
6402    baseline_backend: String,
6403    candidates: Vec<String>,
6404    targets: Vec<String>,
6405    config: GraphDbBackendEvalConfig,
6406    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6407    datasets: Vec<GraphDbBackendEvalDataset>,
6408    promotion: Vec<GraphDbBackendPromotionDecision>,
6409    performance_gate: GraphDbBackendEvalPerformanceGate,
6410    metrics: BTreeMap<String, f64>,
6411    metric_digest_command: String,
6412    warnings: Vec<String>,
6413}
6414
6415#[derive(Clone, Debug, Serialize)]
6416struct GraphDbDoctorCheck {
6417    name: String,
6418    status: String,
6419    fail_closed: bool,
6420    diagnostics: Vec<String>,
6421    repair_commands: Vec<String>,
6422}
6423
6424#[derive(Serialize)]
6425pub(crate) struct GraphDbDoctorReport {
6426    root: String,
6427    #[serde(skip_serializing_if = "Option::is_none")]
6428    scope: Option<String>,
6429    backend: String,
6430    graph_db: String,
6431    #[serde(skip_serializing_if = "Option::is_none")]
6432    convex_snapshot: Option<String>,
6433    status: String,
6434    fail_closed: bool,
6435    checks: Vec<GraphDbDoctorCheck>,
6436    repair_commands: Vec<String>,
6437    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6438    required_indexes: Vec<ConvexRequiredIndex>,
6439}
6440
6441#[derive(Serialize)]
6442struct GraphDbDriftSummary {
6443    node_upserts: usize,
6444    edge_upserts: usize,
6445    node_tombstones: usize,
6446    edge_tombstones: usize,
6447    stale_nodes: usize,
6448    stale_edges: usize,
6449    stale_projection_metadata: usize,
6450    duplicate_failures: usize,
6451    orphan_failures: usize,
6452    missing_required_indexes: usize,
6453}
6454
6455#[derive(Serialize)]
6456struct GraphDbDriftReport {
6457    root: String,
6458    #[serde(skip_serializing_if = "Option::is_none")]
6459    scope: Option<String>,
6460    graph_db: String,
6461    convex_snapshot: String,
6462    status: String,
6463    graph_reads_allowed: bool,
6464    projection_version: String,
6465    local_hash: Option<String>,
6466    snapshot_hash: Option<String>,
6467    summary: GraphDbDriftSummary,
6468    node_upserts: Vec<String>,
6469    edge_upserts: Vec<String>,
6470    node_tombstones: Vec<String>,
6471    edge_tombstones: Vec<String>,
6472    stale_nodes: Vec<String>,
6473    stale_edges: Vec<String>,
6474    diagnostics: Vec<String>,
6475    next_commands: Vec<String>,
6476    required_indexes: Vec<ConvexRequiredIndex>,
6477    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6478    warnings: Vec<String>,
6479}
6480
6481#[derive(Clone, Serialize)]
6482struct GraphDbTombstoneCounts {
6483    nodes: usize,
6484    edges: usize,
6485    total: usize,
6486}
6487
6488#[derive(Clone, Serialize)]
6489struct GraphDbOperatorCounts {
6490    nodes: usize,
6491    edges: usize,
6492    tombstones: GraphDbTombstoneCounts,
6493    #[serde(skip_serializing_if = "Option::is_none")]
6494    file_size_bytes: Option<u64>,
6495    #[serde(skip_serializing_if = "Option::is_none")]
6496    freelist_bytes: Option<u64>,
6497}
6498
6499#[derive(Clone, Serialize)]
6500struct GraphDbCompactionPolicy {
6501    status: String,
6502    tombstone_scan_rows: usize,
6503    live_rows: usize,
6504    file_size_bytes: Option<u64>,
6505    freelist_bytes: Option<u64>,
6506    safe_to_prune_tombstones: bool,
6507    requires_convex_reconciliation: bool,
6508    recommendations: Vec<String>,
6509    proof: Vec<String>,
6510}
6511
6512#[derive(Serialize)]
6513pub(crate) struct GraphDbRefreshSummary {
6514    scope: String,
6515    projection_version: String,
6516    mode: String,
6517    #[serde(skip_serializing_if = "Option::is_none")]
6518    source_watermark: Option<String>,
6519    tombstoned_nodes: usize,
6520    tombstoned_edges: usize,
6521    upserted_nodes: usize,
6522    upserted_edges: usize,
6523    unchanged_nodes: usize,
6524    unchanged_edges: usize,
6525    upserted_properties: usize,
6526    unchanged_properties: usize,
6527    deleted_properties: usize,
6528    deleted_nodes: usize,
6529    deleted_edges: usize,
6530    pruned_tombstones: usize,
6531    #[serde(skip_serializing_if = "Option::is_none")]
6532    file_size_bytes_before: Option<u64>,
6533    #[serde(skip_serializing_if = "Option::is_none")]
6534    file_size_bytes_after: Option<u64>,
6535    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6536    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6537}
6538
6539#[derive(Serialize)]
6540struct GraphDbOperatorReport {
6541    root: String,
6542    #[serde(skip_serializing_if = "Option::is_none")]
6543    scope: Option<String>,
6544    graph_db: String,
6545    operation: String,
6546    status: String,
6547    materialized: bool,
6548    freshness: GraphDbFreshnessReport,
6549    readiness: GraphEffectivenessReadiness,
6550    counts: GraphDbOperatorCounts,
6551    #[serde(skip_serializing_if = "Option::is_none")]
6552    refresh: Option<GraphDbRefreshSummary>,
6553    compaction: GraphDbCompactionPolicy,
6554    #[serde(skip_serializing_if = "Option::is_none")]
6555    recovery: Option<index::ReadOnlyRecovery>,
6556    next_commands: Vec<String>,
6557    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6558    warnings: Vec<String>,
6559}
6560
6561#[derive(Serialize)]
6562pub(crate) struct GraphDbCompactionReport {
6563    root: String,
6564    #[serde(skip_serializing_if = "Option::is_none")]
6565    scope: Option<String>,
6566    graph_db: String,
6567    applied: bool,
6568    pruned_tombstones: usize,
6569    counts_before: GraphDbOperatorCounts,
6570    counts_after: GraphDbOperatorCounts,
6571    compaction_before: GraphDbCompactionPolicy,
6572    compaction_after: GraphDbCompactionPolicy,
6573    reclaimed_bytes: i64,
6574    next_commands: Vec<String>,
6575    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6576    warnings: Vec<String>,
6577}
6578
6579#[derive(Clone, Serialize, Deserialize)]
6580struct GraphDbEvidencePath {
6581    to: String,
6582    kind: String,
6583    label: String,
6584    #[serde(skip_serializing_if = "Option::is_none")]
6585    path: Option<substrate::GraphPath>,
6586    #[serde(skip_serializing_if = "Option::is_none")]
6587    expand: Option<String>,
6588}
6589
6590#[derive(Clone, Serialize, Deserialize)]
6591struct GraphDbFixtureCoverage {
6592    test: String,
6593    fixture: String,
6594    assertions: Vec<String>,
6595}
6596
6597#[derive(Clone, Serialize, Deserialize)]
6598struct GraphDbEvidenceReport {
6599    root: String,
6600    #[serde(skip_serializing_if = "Option::is_none")]
6601    scope: Option<String>,
6602    backend: String,
6603    contract_version: String,
6604    target: String,
6605    packet_id: String,
6606    #[serde(skip_serializing_if = "Option::is_none")]
6607    projection_hash: Option<String>,
6608    freshness: GraphDbFreshnessReport,
6609    target_node: SubstrateTerseGraphNode,
6610    worker_context: Vec<SubstrateTerseGraphNode>,
6611    source_handles: Vec<SubstrateTerseGraphNode>,
6612    worker_results: Vec<SubstrateTerseGraphNode>,
6613    semantic_related: Vec<SubstrateTerseGraphNode>,
6614    shortest_paths: Vec<GraphDbEvidencePath>,
6615    #[serde(skip_serializing_if = "Option::is_none")]
6616    output_budget: Option<GraphDbOutputBudgetReport>,
6617    #[serde(default)]
6618    truncated: bool,
6619    #[serde(skip_serializing_if = "Option::is_none")]
6620    next_cursor: Option<String>,
6621    next_commands: Vec<String>,
6622    replay_commands: Vec<String>,
6623    repair_commands: Vec<String>,
6624    fixture_coverage: GraphDbFixtureCoverage,
6625    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6626    warnings: Vec<String>,
6627}
6628
6629pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6630    root: &'a Path,
6631    scope: Option<&'a str>,
6632    backend: &'a str,
6633    target: &'a str,
6634    preferred_path: Option<&'a str>,
6635    depth: usize,
6636    limit: usize,
6637    cursor: Option<&'a str>,
6638    store: &'a S,
6639    freshness: GraphDbFreshnessReport,
6640    warnings: Vec<String>,
6641}
6642
6643impl GraphDbDoctorReport {
6644    fn new(
6645        root: &Path,
6646        scope: Option<&str>,
6647        backend: &str,
6648        graph_db: &Path,
6649        convex_snapshot: Option<&Path>,
6650    ) -> Self {
6651        Self {
6652            root: root.to_string_lossy().to_string(),
6653            scope: scope.map(str::to_string),
6654            backend: backend.to_string(),
6655            graph_db: graph_db.to_string_lossy().to_string(),
6656            convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6657            status: "ok".to_string(),
6658            fail_closed: false,
6659            checks: Vec::new(),
6660            repair_commands: Vec::new(),
6661            required_indexes: Vec::new(),
6662        }
6663    }
6664
6665    fn push_check(&mut self, check: GraphDbDoctorCheck) {
6666        self.checks.push(check);
6667    }
6668
6669    fn finalize(&mut self) {
6670        self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6671        self.status = if self.fail_closed {
6672            "fail_closed"
6673        } else {
6674            "ok"
6675        }
6676        .to_string();
6677        let mut commands = BTreeSet::new();
6678        for check in &self.checks {
6679            commands.extend(check.repair_commands.iter().cloned());
6680        }
6681        self.repair_commands = commands.into_iter().collect();
6682    }
6683
6684    fn summary(&self) -> String {
6685        self.checks
6686            .iter()
6687            .filter(|check| check.fail_closed)
6688            .flat_map(|check| check.diagnostics.iter())
6689            .take(3)
6690            .cloned()
6691            .collect::<Vec<_>>()
6692            .join("; ")
6693    }
6694}
6695
6696fn graph_db_doctor_check(
6697    name: impl Into<String>,
6698    diagnostics: Vec<String>,
6699    repair_commands: Vec<String>,
6700) -> GraphDbDoctorCheck {
6701    let fail_closed = !diagnostics.is_empty();
6702    GraphDbDoctorCheck {
6703        name: name.into(),
6704        status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6705        fail_closed,
6706        diagnostics,
6707        repair_commands: if fail_closed {
6708            repair_commands
6709        } else {
6710            Vec::new()
6711        },
6712    }
6713}
6714
6715pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6716    scope
6717        .map(|scope| format!(" --scope {}", shell_quote(scope)))
6718        .unwrap_or_default()
6719}
6720
6721fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6722    format!(
6723        "tsift graph-db --path {}{} refresh --json",
6724        shell_quote(root.to_string_lossy().as_ref()),
6725        graph_db_scope_arg(scope)
6726    )
6727}
6728
6729fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6730    graph_db_refresh_command(root, scope)
6731}
6732
6733fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6734    let backup = format!("{}.bak", graph_db.to_string_lossy());
6735    format!(
6736        "mv {} {} && {}",
6737        shell_quote(graph_db.to_string_lossy().as_ref()),
6738        shell_quote(&backup),
6739        graph_db_rebuild_command(root, scope)
6740    )
6741}
6742
6743fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6744    format!(
6745        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6746        shell_quote(root.to_string_lossy().as_ref()),
6747        graph_db_scope_arg(scope)
6748    )
6749}
6750
6751fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6752    substrate::open_graph_read_only_connection_resilient(graph_db)
6753}
6754
6755fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6756    conn.query_row(
6757        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6758        [table],
6759        |row| row.get::<_, bool>(0),
6760    )
6761    .map_err(Into::into)
6762}
6763
6764fn row_usize(row: &Row<'_>, idx: usize) -> rusqlite::Result<usize> {
6765    let value: i64 = row.get(idx)?;
6766    usize::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6767}
6768
6769fn row_u64(row: &Row<'_>, idx: usize) -> rusqlite::Result<u64> {
6770    let value: i64 = row.get(idx)?;
6771    u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6772}
6773
6774fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6775    let sql = match table {
6776        "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6777        "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6778        "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6779        other => bail!("unsupported graph count table {other}"),
6780    };
6781    conn.query_row(sql, [], |row| row_usize(row, 0))
6782        .map_err(Into::into)
6783}
6784
6785fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6786    if !sqlite_table_exists(conn, "graph_tombstones")? {
6787        return Ok(GraphDbTombstoneCounts {
6788            nodes: 0,
6789            edges: 0,
6790            total: 0,
6791        });
6792    }
6793    let mut stmt =
6794        conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6795    let mut rows = stmt.query([])?;
6796    let mut nodes = 0usize;
6797    let mut edges = 0usize;
6798    while let Some(row) = rows.next()? {
6799        let row_kind: String = row.get(0)?;
6800        let count = row_usize(row, 1)?;
6801        match row_kind.as_str() {
6802            "node" => nodes = count,
6803            "edge" => edges = count,
6804            _ => {}
6805        }
6806    }
6807    Ok(GraphDbTombstoneCounts {
6808        nodes,
6809        edges,
6810        total: nodes + edges,
6811    })
6812}
6813
6814fn sqlite_graph_counts_from_cache(
6815    conn: &Connection,
6816    scope: &str,
6817) -> Result<Option<GraphDbOperatorCounts>> {
6818    if !sqlite_table_exists(conn, "graph_operator_stats")? {
6819        return Ok(None);
6820    }
6821    let row = conn
6822        .query_row(
6823            r#"
6824        SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6825        FROM graph_operator_stats
6826        WHERE scope = ?1
6827        "#,
6828            [scope],
6829            |row| {
6830                Ok((
6831                    row_usize(row, 0)?,
6832                    row_usize(row, 1)?,
6833                    row_usize(row, 2)?,
6834                    row_usize(row, 3)?,
6835                    row.get::<_, Option<i64>>(4)?,
6836                    row.get::<_, Option<i64>>(5)?,
6837                ))
6838            },
6839        )
6840        .optional()?;
6841    Ok(row.map(
6842        |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6843            GraphDbOperatorCounts {
6844                nodes,
6845                edges,
6846                tombstones: GraphDbTombstoneCounts {
6847                    nodes: tombstone_nodes,
6848                    edges: tombstone_edges,
6849                    total: tombstone_nodes + tombstone_edges,
6850                },
6851                file_size_bytes: file_size_bytes
6852                    .and_then(|value| u64::try_from(value).ok())
6853                    .or_else(|| sqlite_database_size_bytes(conn).ok()),
6854                freelist_bytes: freelist_bytes
6855                    .and_then(|value| u64::try_from(value).ok())
6856                    .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6857            }
6858        },
6859    ))
6860}
6861
6862fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6863    if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6864        return Ok(counts);
6865    }
6866    let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6867        sqlite_known_table_count(conn, "graph_nodes")?
6868    } else {
6869        0
6870    };
6871    let edges = if sqlite_table_exists(conn, "graph_edges")? {
6872        sqlite_known_table_count(conn, "graph_edges")?
6873    } else {
6874        0
6875    };
6876    Ok(GraphDbOperatorCounts {
6877        nodes,
6878        edges,
6879        tombstones: sqlite_tombstone_counts(conn)?,
6880        file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6881        freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6882    })
6883}
6884
6885fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6886    if !sqlite_table_exists(conn, "graph_nodes")? {
6887        return Ok(0);
6888    }
6889    let count: i64 = conn.query_row(
6890        "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6891        [],
6892        |row| row.get(0),
6893    )?;
6894    Ok(count as usize)
6895}
6896
6897pub(crate) fn graph_db_compaction_policy(
6898    root: &Path,
6899    scope: Option<&str>,
6900    counts: &GraphDbOperatorCounts,
6901    prune_confirmed: bool,
6902) -> GraphDbCompactionPolicy {
6903    let live_rows = counts.nodes + counts.edges;
6904    let tombstone_scan_rows = counts.tombstones.total;
6905    let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6906    let freelist_heavy = counts
6907        .file_size_bytes
6908        .zip(counts.freelist_bytes)
6909        .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6910    let status = if tombstone_heavy || freelist_heavy {
6911        "recommended"
6912    } else {
6913        "not_needed"
6914    }
6915    .to_string();
6916    let mut recommendations = vec![
6917        convex_refresh_command(root, scope),
6918        graph_db_refresh_command(root, scope),
6919        format!(
6920            "tsift graph-db --path {}{} compact --apply --json",
6921            shell_quote(root.to_string_lossy().as_ref()),
6922            graph_db_scope_arg(scope)
6923        ),
6924    ];
6925    if prune_confirmed {
6926        recommendations.push(format!(
6927            "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6928            shell_quote(root.to_string_lossy().as_ref()),
6929            graph_db_scope_arg(scope)
6930        ));
6931    }
6932    let proof = vec![
6933        format!("{live_rows} live graph row(s)"),
6934        format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6935        format!(
6936            "graph.db file_size={} byte(s), freelist={} byte(s)",
6937            counts.file_size_bytes.unwrap_or(0),
6938            counts.freelist_bytes.unwrap_or(0)
6939        ),
6940    ];
6941    GraphDbCompactionPolicy {
6942        status,
6943        tombstone_scan_rows,
6944        live_rows,
6945        file_size_bytes: counts.file_size_bytes,
6946        freelist_bytes: counts.freelist_bytes,
6947        safe_to_prune_tombstones: prune_confirmed,
6948        requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6949        recommendations,
6950        proof,
6951    }
6952}
6953
6954fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6955    let page_count = conn.query_row("PRAGMA page_count", [], |row| row_u64(row, 0))?;
6956    let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
6957    Ok(page_count.saturating_mul(page_size))
6958}
6959
6960fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6961    let freelist_count = conn.query_row("PRAGMA freelist_count", [], |row| row_u64(row, 0))?;
6962    let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
6963    Ok(freelist_count.saturating_mul(page_size))
6964}
6965
6966fn sqlite_graph_tombstone_retention_diagnostics(
6967    conn: &Connection,
6968    scope: &str,
6969) -> Result<Vec<String>> {
6970    if !sqlite_table_exists(conn, "graph_tombstones")? {
6971        return Ok(Vec::new());
6972    }
6973    let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6974    let counts = match cached.clone() {
6975        Some(counts) => counts,
6976        None => sqlite_graph_counts(conn, scope)?,
6977    };
6978    let live_rows = counts.nodes + counts.edges;
6979    let file_size = counts.file_size_bytes.unwrap_or(0);
6980    let freelist = counts.freelist_bytes.unwrap_or(0);
6981    let stale_live_tombstones = if cached.is_some() {
6982        0
6983    } else {
6984        let mut live_keys = BTreeSet::new();
6985        if sqlite_table_exists(conn, "graph_nodes")? {
6986            let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6987            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6988                live_keys.insert(format!("node:{}", row?));
6989            }
6990        }
6991        if sqlite_table_exists(conn, "graph_edges")? {
6992            let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6993            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6994                live_keys.insert(format!("edge:{}", row?));
6995            }
6996        }
6997        let mut stale_live_tombstones = 0usize;
6998        let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6999        for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7000            if live_keys.contains(&row?) {
7001                stale_live_tombstones += 1;
7002            }
7003        }
7004        stale_live_tombstones
7005    };
7006
7007    let mut diagnostics = Vec::new();
7008    if stale_live_tombstones > 0 {
7009        diagnostics.push(format!(
7010            "{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"
7011        ));
7012    }
7013    if counts.tombstones.total > live_rows.max(1) {
7014        let source = if cached.is_some() {
7015            "cached refresh stats"
7016        } else {
7017            "live row scan"
7018        };
7019        diagnostics.push(format!(
7020            "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.",
7021            counts.tombstones.total,
7022            live_rows,
7023            source,
7024            file_size,
7025            freelist,
7026            counts.tombstones.total
7027        ));
7028    }
7029    Ok(diagnostics)
7030}
7031
7032fn sqlite_graph_freshness_from_conn(
7033    conn: &Connection,
7034    scope: &str,
7035) -> Result<GraphDbFreshnessReport> {
7036    if !sqlite_table_exists(conn, "graph_projection_versions")? {
7037        return Ok(GraphDbFreshnessReport {
7038            status: "missing".to_string(),
7039            fail_closed: true,
7040            projection_version: None,
7041            content_hash: None,
7042            source_watermark: None,
7043            diagnostics: vec![
7044                "graph projection metadata table is missing; refresh graph.db before trusting reads"
7045                    .to_string(),
7046            ],
7047        });
7048    }
7049    let version = conn
7050        .query_row(
7051            r#"
7052            SELECT projection_version, content_hash, source_watermark
7053            FROM graph_projection_versions
7054            WHERE scope = ?1
7055            "#,
7056            [scope],
7057            |row| {
7058                Ok((
7059                    row.get::<_, String>(0)?,
7060                    row.get::<_, Option<String>>(1)?,
7061                    row.get::<_, Option<String>>(2)?,
7062                ))
7063            },
7064        )
7065        .optional()?;
7066    let Some((projection_version, content_hash, source_watermark)) = version else {
7067        return Ok(GraphDbFreshnessReport {
7068            status: "missing".to_string(),
7069            fail_closed: true,
7070            projection_version: None,
7071            content_hash: None,
7072            source_watermark: None,
7073            diagnostics: vec![
7074                "graph projection metadata is missing; refresh graph.db before trusting reads"
7075                    .to_string(),
7076            ],
7077        });
7078    };
7079
7080    let mut diagnostics = Vec::new();
7081    if projection_version != GRAPH_PROJECTION_VERSION {
7082        diagnostics.push(format!(
7083            "projection version mismatch: expected {} got {}",
7084            GRAPH_PROJECTION_VERSION, projection_version
7085        ));
7086    }
7087    if content_hash.is_none() {
7088        diagnostics.push("projection content hash is missing".to_string());
7089    }
7090    let fail_closed = !diagnostics.is_empty();
7091    Ok(GraphDbFreshnessReport {
7092        status: if fail_closed { "stale" } else { "current" }.to_string(),
7093        fail_closed,
7094        projection_version: Some(projection_version),
7095        content_hash,
7096        source_watermark,
7097        diagnostics,
7098    })
7099}
7100
7101fn graph_db_operator_next_commands(
7102    root: &Path,
7103    scope: Option<&str>,
7104    include_refresh: bool,
7105) -> Vec<String> {
7106    let mut commands = Vec::new();
7107    if include_refresh {
7108        commands.push(graph_db_refresh_command(root, scope));
7109    }
7110    commands.push(format!(
7111        "tsift graph-db --path {}{} doctor --json",
7112        shell_quote(root.to_string_lossy().as_ref()),
7113        graph_db_scope_arg(scope)
7114    ));
7115    commands.push(format!(
7116        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
7117        shell_quote(root.to_string_lossy().as_ref()),
7118        graph_db_scope_arg(scope)
7119    ));
7120    commands.push(format!(
7121        "tsift convex-sync {}{} --remote-snapshot --apply --json",
7122        shell_quote(root.to_string_lossy().as_ref()),
7123        graph_db_scope_arg(scope)
7124    ));
7125    commands
7126}
7127
7128pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
7129    match recovery {
7130        index::ReadOnlyRecovery::SnapshotFallback => {
7131            "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
7132        }
7133        index::ReadOnlyRecovery::SnapshotFallbackWal => {
7134            "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
7135        }
7136    }
7137}
7138
7139fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
7140    let mut stmt = conn.prepare(sql)?;
7141    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7142    let mut values = BTreeSet::new();
7143    for row in rows {
7144        values.insert(row?);
7145    }
7146    Ok(values)
7147}
7148
7149fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
7150    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
7151    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
7152    let mut columns = BTreeSet::new();
7153    for row in rows {
7154        columns.insert(row?);
7155    }
7156    Ok(columns)
7157}
7158
7159fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7160    let mut diagnostics = Vec::new();
7161    let user_version: i64 =
7162        conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
7163    if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
7164        diagnostics.push(format!(
7165            "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7166        ));
7167    } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
7168        diagnostics.push(format!(
7169            "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7170        ));
7171    }
7172
7173    let tables = sqlite_string_set(
7174        conn,
7175        "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
7176    )?;
7177    let required_tables = [
7178        (
7179            "graph_nodes",
7180            vec![
7181                "id",
7182                "kind",
7183                "label",
7184                "properties_json",
7185                "provenance_json",
7186                "freshness_json",
7187                "row_hash",
7188                "source_watermark",
7189            ],
7190        ),
7191        (
7192            "graph_edges",
7193            vec![
7194                "edge_key",
7195                "from_id",
7196                "to_id",
7197                "kind",
7198                "properties_json",
7199                "provenance_json",
7200                "freshness_json",
7201                "row_hash",
7202                "source_watermark",
7203            ],
7204        ),
7205        (
7206            "graph_projection_versions",
7207            vec![
7208                "scope",
7209                "projection_version",
7210                "content_hash",
7211                "source_watermark",
7212                "observed_at_unix",
7213            ],
7214        ),
7215        (
7216            "graph_tombstones",
7217            vec!["row_key", "row_kind", "deleted_at_unix"],
7218        ),
7219        ("graph_node_properties", vec!["node_id", "key", "value"]),
7220        ("graph_edge_properties", vec!["edge_key", "key", "value"]),
7221    ];
7222    for (table, required_columns) in required_tables {
7223        if !tables.contains(table) {
7224            diagnostics.push(format!("graph.db schema drift: missing table {table}"));
7225            continue;
7226        }
7227        let columns = sqlite_column_names(conn, table)?;
7228        for column in required_columns {
7229            if !columns.contains(column) {
7230                diagnostics.push(format!(
7231                    "graph.db schema drift: missing column {table}.{column}"
7232                ));
7233            }
7234        }
7235    }
7236
7237    let indexes = sqlite_string_set(
7238        conn,
7239        "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
7240    )?;
7241    for index in [
7242        "idx_graph_nodes_kind",
7243        "idx_graph_edges_from_kind",
7244        "idx_graph_edges_to_kind",
7245        "idx_graph_edges_edge_key",
7246        "idx_graph_node_properties_key_value_node",
7247        "idx_graph_edge_properties_key_value_edge",
7248    ] {
7249        if !indexes.contains(index) {
7250            diagnostics.push(format!("graph.db schema drift: missing index {index}"));
7251        }
7252    }
7253
7254    if tables.contains("graph_edges") {
7255        let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
7256        let rows = stmt.query_map([], |row| {
7257            Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
7258        })?;
7259        let mut fks = BTreeSet::new();
7260        for row in rows {
7261            fks.insert(row?);
7262        }
7263        for expected in [
7264            ("from_id".to_string(), "id".to_string()),
7265            ("to_id".to_string(), "id".to_string()),
7266        ] {
7267            if !fks.contains(&expected) {
7268                diagnostics.push(format!(
7269                    "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
7270                    expected.0, expected.1
7271                ));
7272            }
7273        }
7274    }
7275
7276    Ok(diagnostics)
7277}
7278
7279fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
7280    let mut stmt = conn.prepare(sql)?;
7281    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7282    let mut diagnostics = Vec::new();
7283    for row in rows {
7284        diagnostics.push(row?);
7285    }
7286    Ok(diagnostics)
7287}
7288
7289fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7290    let mut diagnostics = sqlite_query_diagnostics(
7291        conn,
7292        r#"
7293        SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
7294        FROM graph_nodes
7295        GROUP BY id
7296        HAVING COUNT(*) > 1
7297        ORDER BY id
7298        "#,
7299    )?;
7300    diagnostics.extend(sqlite_query_diagnostics(
7301        conn,
7302        r#"
7303        SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
7304        FROM graph_edges
7305        GROUP BY from_id, to_id, kind
7306        HAVING COUNT(*) > 1
7307        ORDER BY from_id, kind, to_id
7308        "#,
7309    )?);
7310    diagnostics.extend(sqlite_query_diagnostics(
7311        conn,
7312        r#"
7313        SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
7314        FROM graph_edges
7315        GROUP BY edge_key
7316        HAVING COUNT(*) > 1
7317        ORDER BY edge_key
7318        "#,
7319    )?);
7320    Ok(diagnostics)
7321}
7322
7323fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7324    sqlite_query_diagnostics(
7325        conn,
7326        r#"
7327        SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7328        FROM graph_edges e
7329        LEFT JOIN graph_nodes n ON n.id = e.from_id
7330        WHERE n.id IS NULL
7331        UNION ALL
7332        SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7333        FROM graph_edges e
7334        LEFT JOIN graph_nodes n ON n.id = e.to_id
7335        WHERE n.id IS NULL
7336        ORDER BY 1
7337        "#,
7338    )
7339}
7340
7341fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7342    let mut diagnostics = Vec::new();
7343    let mut node_stmt = conn.prepare(
7344        "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7345    )?;
7346    let node_rows = node_stmt.query_map([], |row| {
7347        Ok((
7348            row.get::<_, String>(0)?,
7349            row.get::<_, String>(1)?,
7350            row.get::<_, String>(2)?,
7351            row.get::<_, Option<String>>(3)?,
7352        ))
7353    })?;
7354    for row in node_rows {
7355        let (id, properties_json, provenance_json, freshness_json) = row?;
7356        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7357            diagnostics.push(format!(
7358                "graph_nodes {id} properties_json is invalid: {err}"
7359            ));
7360        }
7361        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7362            diagnostics.push(format!(
7363                "graph_nodes {id} provenance_json is invalid: {err}"
7364            ));
7365        }
7366        if let Some(freshness_json) = freshness_json
7367            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7368        {
7369            diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
7370        }
7371    }
7372
7373    let mut edge_stmt = conn.prepare(
7374        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7375    )?;
7376    let edge_rows = edge_stmt.query_map([], |row| {
7377        Ok((
7378            row.get::<_, String>(0)?,
7379            row.get::<_, String>(1)?,
7380            row.get::<_, String>(2)?,
7381            row.get::<_, String>(3)?,
7382            row.get::<_, String>(4)?,
7383            row.get::<_, String>(5)?,
7384            row.get::<_, Option<String>>(6)?,
7385        ))
7386    })?;
7387    for row in edge_rows {
7388        let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
7389            row?;
7390        let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
7391        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7392            diagnostics.push(format!(
7393                "graph_edges {edge} properties_json is invalid: {err}"
7394            ));
7395        }
7396        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7397            diagnostics.push(format!(
7398                "graph_edges {edge} provenance_json is invalid: {err}"
7399            ));
7400        }
7401        if let Some(freshness_json) = freshness_json
7402            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7403        {
7404            diagnostics.push(format!(
7405                "graph_edges {edge} freshness_json is invalid: {err}"
7406            ));
7407        }
7408    }
7409    Ok(diagnostics)
7410}
7411
7412fn sqlite_graph_projection_metadata_diagnostics(
7413    conn: &Connection,
7414    scope: Option<&str>,
7415) -> Result<Vec<String>> {
7416    let mut diagnostics = Vec::new();
7417    let scope_key = scope.unwrap_or("root");
7418    let version = conn
7419        .query_row(
7420            r#"
7421            SELECT projection_version, content_hash, source_watermark
7422            FROM graph_projection_versions
7423            WHERE scope = ?1
7424            "#,
7425            [scope_key],
7426            |row| {
7427                Ok((
7428                    row.get::<_, String>(0)?,
7429                    row.get::<_, Option<String>>(1)?,
7430                    row.get::<_, Option<String>>(2)?,
7431                ))
7432            },
7433        )
7434        .optional()?;
7435    let Some((projection_version, content_hash, _source_watermark)) = version else {
7436        diagnostics.push(format!(
7437            "graph projection metadata is missing for scope {scope_key}"
7438        ));
7439        return Ok(diagnostics);
7440    };
7441    if projection_version != GRAPH_PROJECTION_VERSION {
7442        diagnostics.push(format!(
7443            "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
7444        ));
7445    }
7446    if content_hash.is_none() {
7447        diagnostics.push("projection content hash is missing".to_string());
7448    }
7449
7450    let meta_id = graph_projection_meta_id(scope);
7451    let meta_properties = conn
7452        .query_row(
7453            "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
7454            (&meta_id, GRAPH_PROJECTION_META_KIND),
7455            |row| row.get::<_, String>(0),
7456        )
7457        .optional()?;
7458    let Some(meta_properties) = meta_properties else {
7459        diagnostics.push(format!("projection_meta node {meta_id} is missing"));
7460        return Ok(diagnostics);
7461    };
7462    let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
7463        .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
7464    if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
7465        diagnostics.push(format!(
7466            "projection_meta node {meta_id} has stale projection_version"
7467        ));
7468    }
7469    if properties.get("content_hash") != content_hash.as_ref() {
7470        diagnostics.push(format!(
7471            "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
7472        ));
7473    }
7474    Ok(diagnostics)
7475}
7476
7477pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
7478    let mut node_stmt = conn.prepare(
7479        "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7480    )?;
7481    let node_rows = node_stmt.query_map([], |row| {
7482        let properties_json: String = row.get(3)?;
7483        let provenance_json: String = row.get(4)?;
7484        let freshness_json: Option<String> = row.get(5)?;
7485        Ok((
7486            row.get::<_, String>(0)?,
7487            row.get::<_, String>(1)?,
7488            row.get::<_, String>(2)?,
7489            properties_json,
7490            provenance_json,
7491            freshness_json,
7492        ))
7493    })?;
7494    let mut nodes = Vec::new();
7495    for row in node_rows {
7496        let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
7497        nodes.push(ConvexNodeRow {
7498            external_id,
7499            kind,
7500            label,
7501            properties: serde_json::from_str(&properties_json)?,
7502            provenance: serde_json::from_str(&provenance_json)?,
7503            freshness: freshness_json
7504                .map(|value| serde_json::from_str(&value))
7505                .transpose()?,
7506        });
7507    }
7508
7509    let mut edge_stmt = conn.prepare(
7510        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7511    )?;
7512    let edge_rows = edge_stmt.query_map([], |row| {
7513        let properties_json: String = row.get(4)?;
7514        let provenance_json: String = row.get(5)?;
7515        let freshness_json: Option<String> = row.get(6)?;
7516        Ok((
7517            row.get::<_, String>(0)?,
7518            row.get::<_, String>(1)?,
7519            row.get::<_, String>(2)?,
7520            row.get::<_, String>(3)?,
7521            properties_json,
7522            provenance_json,
7523            freshness_json,
7524        ))
7525    })?;
7526    let mut edges = Vec::new();
7527    for row in edge_rows {
7528        let (
7529            edge_key,
7530            from_external_id,
7531            to_external_id,
7532            kind,
7533            properties_json,
7534            provenance_json,
7535            freshness_json,
7536        ) = row?;
7537        edges.push(ConvexEdgeRow {
7538            edge_key,
7539            from_external_id,
7540            to_external_id,
7541            kind,
7542            properties: serde_json::from_str(&properties_json)?,
7543            provenance: serde_json::from_str(&provenance_json)?,
7544            freshness: freshness_json
7545                .map(|value| serde_json::from_str(&value))
7546                .transpose()?,
7547        });
7548    }
7549    Ok(ConvexProjectionRows { nodes, edges })
7550}
7551
7552fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
7553    format!("{}.{}({})", index.table, index.name, index.fields.join(","))
7554}
7555
7556fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
7557    value
7558        .get("indexes")
7559        .or_else(|| value.get("requiredIndexes"))
7560        .or_else(|| {
7561            value
7562                .get("metadata")
7563                .and_then(|metadata| metadata.get("indexes"))
7564        })
7565}
7566
7567fn convex_snapshot_declared_indexes(
7568    value: &serde_json::Value,
7569) -> Result<Option<Vec<ConvexRequiredIndex>>> {
7570    convex_snapshot_index_value(value)
7571        .map(|indexes| {
7572            serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
7573                .context("parsing Convex snapshot index metadata")
7574        })
7575        .transpose()
7576}
7577
7578fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
7579    let required = convex_required_indexes();
7580    let Some(declared) = convex_snapshot_declared_indexes(value)? else {
7581        return Ok(vec![format!(
7582            "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
7583            required
7584                .iter()
7585                .map(convex_required_index_label)
7586                .collect::<Vec<_>>()
7587                .join(", ")
7588        )]);
7589    };
7590    let declared = declared.into_iter().collect::<BTreeSet<_>>();
7591    let missing = required
7592        .iter()
7593        .filter(|index| !declared.contains(*index))
7594        .map(convex_required_index_label)
7595        .collect::<Vec<_>>();
7596    if missing.is_empty() {
7597        Ok(Vec::new())
7598    } else {
7599        Ok(vec![format!(
7600            "Convex snapshot is missing required index metadata: {}",
7601            missing.join(", ")
7602        )])
7603    }
7604}
7605
7606pub(crate) fn load_convex_projection_snapshot_value(
7607    snapshot_path: &Path,
7608) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7609    let content = fs::read_to_string(snapshot_path).with_context(|| {
7610        format!(
7611            "reading Convex projection snapshot {}",
7612            snapshot_path.display()
7613        )
7614    })?;
7615    let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7616        format!(
7617            "parsing Convex projection snapshot {}",
7618            snapshot_path.display()
7619        )
7620    })?;
7621    let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7622        .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7623    Ok((rows, value))
7624}
7625
7626pub(crate) fn append_sqlite_graph_doctor_checks(
7627    report: &mut GraphDbDoctorReport,
7628    root: &Path,
7629    scope: Option<&str>,
7630    graph_db: &Path,
7631) -> Option<substrate::SqliteReadOnlyConnection> {
7632    let rebuild = graph_db_rebuild_command(root, scope);
7633    let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7634    if !graph_db.exists() {
7635        report.push_check(graph_db_doctor_check(
7636            "sqlite_graph_db_exists",
7637            vec![format!("graph.db is missing at {}", graph_db.display())],
7638            vec![rebuild],
7639        ));
7640        return None;
7641    }
7642    report.push_check(graph_db_doctor_check(
7643        "sqlite_graph_db_exists",
7644        Vec::new(),
7645        vec![rebuild.clone()],
7646    ));
7647
7648    let conn = match open_sqlite_graph_db_readonly(graph_db) {
7649        Ok(conn) => conn,
7650        Err(err) => {
7651            report.push_check(graph_db_doctor_check(
7652                "sqlite_graph_db_open",
7653                vec![err.to_string()],
7654                vec![backup_rebuild],
7655            ));
7656            return None;
7657        }
7658    };
7659    report.push_check(graph_db_doctor_check(
7660        "sqlite_graph_db_open",
7661        Vec::new(),
7662        vec![rebuild.clone()],
7663    ));
7664    if let Some(recovery) = conn.recovery() {
7665        report.push_check(GraphDbDoctorCheck {
7666            name: "sqlite_graph_db_read_recovery".to_string(),
7667            status: "recovered".to_string(),
7668            fail_closed: false,
7669            diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7670            repair_commands: Vec::new(),
7671        });
7672    }
7673
7674    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7675        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7676    report.push_check(graph_db_doctor_check(
7677        "sqlite_schema",
7678        schema_diagnostics,
7679        vec![backup_rebuild.clone()],
7680    ));
7681
7682    let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7683        .unwrap_or_else(|err| {
7684            vec![format!(
7685                "graph projection metadata inspection failed: {err}"
7686            )]
7687        });
7688    report.push_check(graph_db_doctor_check(
7689        "sqlite_projection_metadata",
7690        metadata_diagnostics,
7691        vec![rebuild.clone()],
7692    ));
7693
7694    let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7695        .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7696    report.push_check(graph_db_doctor_check(
7697        "sqlite_duplicate_ids",
7698        duplicate_diagnostics,
7699        vec![backup_rebuild.clone()],
7700    ));
7701
7702    let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7703        .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7704    report.push_check(graph_db_doctor_check(
7705        "sqlite_orphan_edges",
7706        orphan_diagnostics,
7707        vec![rebuild.clone()],
7708    ));
7709
7710    let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7711        .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7712    report.push_check(graph_db_doctor_check(
7713        "sqlite_row_json",
7714        json_diagnostics,
7715        vec![backup_rebuild],
7716    ));
7717
7718    let tombstone_diagnostics =
7719        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7720            .unwrap_or_else(|err| {
7721                vec![format!(
7722                    "graph tombstone retention inspection failed: {err}"
7723                )]
7724            });
7725    report.push_check(GraphDbDoctorCheck {
7726        name: "sqlite_tombstone_retention".to_string(),
7727        status: if tombstone_diagnostics.is_empty() {
7728            "ok".to_string()
7729        } else {
7730            "warning".to_string()
7731        },
7732        fail_closed: false,
7733        diagnostics: tombstone_diagnostics,
7734        repair_commands: Vec::new(),
7735    });
7736    let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7737        Ok(counts) => {
7738            let policy = graph_db_compaction_policy(root, scope, &counts, false);
7739            GraphDbDoctorCheck {
7740                name: "sqlite_compaction_policy".to_string(),
7741                status: policy.status.clone(),
7742                fail_closed: false,
7743                diagnostics: policy.proof,
7744                repair_commands: if policy.status == "recommended" {
7745                    policy.recommendations
7746                } else {
7747                    Vec::new()
7748                },
7749            }
7750        }
7751        Err(err) => GraphDbDoctorCheck {
7752            name: "sqlite_compaction_policy".to_string(),
7753            status: "warning".to_string(),
7754            fail_closed: false,
7755            diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7756            repair_commands: Vec::new(),
7757        },
7758    };
7759    report.push_check(compaction_check);
7760
7761    Some(conn)
7762}
7763
7764pub(crate) fn append_convex_snapshot_doctor_checks(
7765    report: &mut GraphDbDoctorReport,
7766    root: &Path,
7767    scope: Option<&str>,
7768    local_rows: Option<&ConvexProjectionRows>,
7769    snapshot_path: Option<&Path>,
7770) {
7771    let repair = convex_refresh_command(root, scope);
7772    let Some(snapshot_path) = snapshot_path else {
7773        report.push_check(graph_db_doctor_check(
7774            "convex_snapshot_present",
7775            vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7776            vec![format!(
7777                "tsift convex-sync {}{} --json > convex-rows.json",
7778                shell_quote(root.to_string_lossy().as_ref()),
7779                graph_db_scope_arg(scope)
7780            )],
7781        ));
7782        return;
7783    };
7784    report.push_check(graph_db_doctor_check(
7785        "convex_snapshot_present",
7786        Vec::new(),
7787        vec![repair.clone()],
7788    ));
7789
7790    let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7791        Ok(snapshot) => snapshot,
7792        Err(err) => {
7793            report.push_check(graph_db_doctor_check(
7794                "convex_snapshot_parse",
7795                vec![err.to_string()],
7796                vec![repair],
7797            ));
7798            return;
7799        }
7800    };
7801    report.push_check(graph_db_doctor_check(
7802        "convex_snapshot_parse",
7803        Vec::new(),
7804        vec![repair.clone()],
7805    ));
7806
7807    let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7808    report.push_check(graph_db_doctor_check(
7809        "convex_snapshot_rows",
7810        row_diagnostics,
7811        vec![repair.clone()],
7812    ));
7813
7814    let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7815        .unwrap_or_else(|err| vec![err.to_string()]);
7816    report.required_indexes = convex_required_indexes();
7817    report.push_check(graph_db_doctor_check(
7818        "convex_required_indexes",
7819        index_diagnostics,
7820        vec![
7821            "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7822                .to_string(),
7823        ],
7824    ));
7825
7826    if let Some(local_rows) = local_rows {
7827        let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7828        report.push_check(graph_db_doctor_check(
7829            "convex_projection_freshness",
7830            freshness.diagnostics,
7831            vec![repair],
7832        ));
7833    } else {
7834        report.push_check(graph_db_doctor_check(
7835            "convex_projection_freshness",
7836            vec![
7837                "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7838                    .to_string(),
7839            ],
7840            vec![graph_db_rebuild_command(root, scope)],
7841        ));
7842    }
7843}
7844
7845fn graph_db_convex_snapshot_doctor_command(
7846    root: &Path,
7847    scope: Option<&str>,
7848    snapshot_path: &Path,
7849) -> String {
7850    format!(
7851        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7852        shell_quote(root.to_string_lossy().as_ref()),
7853        graph_db_scope_arg(scope),
7854        shell_quote(snapshot_path.to_string_lossy().as_ref())
7855    )
7856}
7857
7858fn graph_db_convex_snapshot_read_command(
7859    root: &Path,
7860    scope: Option<&str>,
7861    snapshot_path: &Path,
7862) -> String {
7863    format!(
7864        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7865        shell_quote(root.to_string_lossy().as_ref()),
7866        graph_db_scope_arg(scope),
7867        shell_quote(snapshot_path.to_string_lossy().as_ref())
7868    )
7869}
7870
7871fn convex_sync_snapshot_diff_command(
7872    root: &Path,
7873    scope: Option<&str>,
7874    snapshot_path: &Path,
7875) -> String {
7876    format!(
7877        "tsift convex-sync {}{} --snapshot {} --json",
7878        shell_quote(root.to_string_lossy().as_ref()),
7879        graph_db_scope_arg(scope),
7880        shell_quote(snapshot_path.to_string_lossy().as_ref())
7881    )
7882}
7883
7884pub(crate) struct GraphDbDriftInput<'a> {
7885    root: &'a Path,
7886    scope: Option<&'a str>,
7887    graph_db: &'a Path,
7888    snapshot_path: &'a Path,
7889    local: &'a ConvexProjectionRows,
7890    snapshot: &'a ConvexProjectionRows,
7891    snapshot_value: &'a serde_json::Value,
7892    warnings: Vec<String>,
7893}
7894
7895pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7896    let GraphDbDriftInput {
7897        root,
7898        scope,
7899        graph_db,
7900        snapshot_path,
7901        local,
7902        snapshot,
7903        snapshot_value,
7904        warnings,
7905    } = input;
7906    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7907    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7908        convex_rows_diff(local, Some(snapshot));
7909    let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7910    let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7911        .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7912    let local_hash = freshness.local_hash.clone();
7913    let snapshot_hash = freshness.snapshot_hash.clone();
7914    let stale_nodes = freshness.stale_nodes.clone();
7915    let stale_edges = freshness.stale_edges.clone();
7916
7917    let duplicate_failures = row_diagnostics
7918        .iter()
7919        .filter(|diagnostic| diagnostic.contains("duplicate"))
7920        .count();
7921    let orphan_failures = row_diagnostics
7922        .iter()
7923        .filter(|diagnostic| diagnostic.contains("references missing"))
7924        .count();
7925    let missing_required_indexes = index_diagnostics.len();
7926    let stale_projection_metadata =
7927        usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7928    let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7929    let has_drift = freshness.fail_closed
7930        || !node_upserts.is_empty()
7931        || !edge_upserts.is_empty()
7932        || !node_tombstones.is_empty()
7933        || !edge_tombstones.is_empty();
7934    let status = if hard_failures > 0 {
7935        "fail_closed"
7936    } else if has_drift {
7937        "drift"
7938    } else {
7939        "current"
7940    }
7941    .to_string();
7942
7943    let mut diagnostics = Vec::new();
7944    diagnostics.extend(row_diagnostics);
7945    diagnostics.extend(index_diagnostics);
7946    diagnostics.extend(freshness.diagnostics.clone());
7947    if has_drift {
7948        diagnostics.push(format!(
7949            "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7950            node_upserts.len(),
7951            edge_upserts.len(),
7952            node_tombstones.len(),
7953            edge_tombstones.len()
7954        ));
7955    }
7956
7957    let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7958        root,
7959        scope,
7960        snapshot_path,
7961    )];
7962    if status == "current" {
7963        next_commands.push(graph_db_convex_snapshot_read_command(
7964            root,
7965            scope,
7966            snapshot_path,
7967        ));
7968    } else {
7969        next_commands.push(convex_sync_snapshot_diff_command(
7970            root,
7971            scope,
7972            snapshot_path,
7973        ));
7974        next_commands.push(convex_refresh_command(root, scope));
7975    }
7976
7977    GraphDbDriftReport {
7978        root: root.to_string_lossy().to_string(),
7979        scope: scope.map(str::to_string),
7980        graph_db: graph_db.to_string_lossy().to_string(),
7981        convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7982        status: status.clone(),
7983        graph_reads_allowed: status == "current",
7984        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7985        local_hash,
7986        snapshot_hash,
7987        summary: GraphDbDriftSummary {
7988            node_upserts: node_upserts.len(),
7989            edge_upserts: edge_upserts.len(),
7990            node_tombstones: node_tombstones.len(),
7991            edge_tombstones: edge_tombstones.len(),
7992            stale_nodes: stale_nodes.len(),
7993            stale_edges: stale_edges.len(),
7994            stale_projection_metadata,
7995            duplicate_failures,
7996            orphan_failures,
7997            missing_required_indexes,
7998        },
7999        node_upserts: node_upserts
8000            .into_iter()
8001            .map(|row| row.external_id)
8002            .collect(),
8003        edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
8004        node_tombstones,
8005        edge_tombstones,
8006        stale_nodes,
8007        stale_edges,
8008        diagnostics,
8009        next_commands,
8010        required_indexes: convex_required_indexes(),
8011        warnings,
8012    }
8013}
8014
8015pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
8016    println!(
8017        "graph-db drift status: {} reads_allowed: {}",
8018        report.status, report.graph_reads_allowed
8019    );
8020    println!("graph_db: {}", report.graph_db);
8021    println!("convex_snapshot: {}", report.convex_snapshot);
8022    println!(
8023        "upserts: {} node(s), {} edge(s)",
8024        report.summary.node_upserts, report.summary.edge_upserts
8025    );
8026    println!(
8027        "tombstones: {} node(s), {} edge(s)",
8028        report.summary.node_tombstones, report.summary.edge_tombstones
8029    );
8030    for diagnostic in &report.diagnostics {
8031        println!("diagnostic: {diagnostic}");
8032    }
8033    for command in &report.next_commands {
8034        println!("next: {command}");
8035    }
8036}
8037
8038pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
8039    println!(
8040        "graph-db doctor backend: {} status: {}",
8041        report.backend, report.status
8042    );
8043    println!("graph_db: {}", report.graph_db);
8044    if let Some(snapshot) = &report.convex_snapshot {
8045        println!("convex_snapshot: {snapshot}");
8046    }
8047    for check in &report.checks {
8048        println!("check: {} {}", check.name, check.status);
8049        for diagnostic in &check.diagnostics {
8050            println!("  diagnostic: {diagnostic}");
8051        }
8052    }
8053    for command in &report.repair_commands {
8054        println!("repair: {command}");
8055    }
8056}
8057
8058pub(crate) fn graph_db_operator_report_from_disk(
8059    root: &Path,
8060    scope: Option<&str>,
8061    graph_db: &Path,
8062    operation: &str,
8063    refresh: Option<GraphDbRefreshSummary>,
8064    warnings: Vec<String>,
8065) -> Result<GraphDbOperatorReport> {
8066    if !graph_db.exists() {
8067        let next_commands = graph_db_operator_next_commands(root, scope, true);
8068        let counts = GraphDbOperatorCounts {
8069            nodes: 0,
8070            edges: 0,
8071            tombstones: GraphDbTombstoneCounts {
8072                nodes: 0,
8073                edges: 0,
8074                total: 0,
8075            },
8076            file_size_bytes: None,
8077            freelist_bytes: None,
8078        };
8079        return Ok(GraphDbOperatorReport {
8080            root: root.to_string_lossy().to_string(),
8081            scope: scope.map(str::to_string),
8082            graph_db: graph_db.to_string_lossy().to_string(),
8083            operation: operation.to_string(),
8084            status: "missing".to_string(),
8085            materialized: false,
8086            freshness: GraphDbFreshnessReport {
8087                status: "missing".to_string(),
8088                fail_closed: true,
8089                projection_version: None,
8090                content_hash: None,
8091                source_watermark: None,
8092                diagnostics: vec![
8093                    "graph.db is missing; run graph-db refresh before trusting graph reads"
8094                        .to_string(),
8095                ],
8096            },
8097            readiness: graph_effectiveness_blocked(
8098                "graph_db_missing",
8099                vec![
8100                    "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
8101                ],
8102                next_commands.clone(),
8103            ),
8104            counts: counts.clone(),
8105            refresh,
8106            compaction: graph_db_compaction_policy(root, scope, &counts, false),
8107            recovery: None,
8108            next_commands,
8109            warnings,
8110        });
8111    }
8112
8113    let conn = open_sqlite_graph_db_readonly(graph_db)?;
8114    let recovery = conn.recovery();
8115    let mut warnings = warnings;
8116    if let Some(recovery) = recovery {
8117        warnings.push(graph_db_read_recovery_diagnostic(recovery));
8118    }
8119    let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
8120    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
8121        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
8122    if !schema_diagnostics.is_empty() {
8123        freshness.diagnostics.extend(schema_diagnostics);
8124        freshness.fail_closed = true;
8125        freshness.status = "stale".to_string();
8126    }
8127    let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
8128    let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
8129    warnings.extend(
8130        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
8131            .unwrap_or_else(|err| {
8132                vec![format!(
8133                    "graph tombstone retention inspection failed: {err}"
8134                )]
8135            }),
8136    );
8137    let status = if freshness.fail_closed {
8138        "stale"
8139    } else {
8140        "current"
8141    }
8142    .to_string();
8143
8144    Ok(GraphDbOperatorReport {
8145        root: root.to_string_lossy().to_string(),
8146        scope: scope.map(str::to_string),
8147        graph_db: graph_db.to_string_lossy().to_string(),
8148        operation: operation.to_string(),
8149        status,
8150        materialized: true,
8151        freshness,
8152        readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
8153        compaction: graph_db_compaction_policy(root, scope, &counts, false),
8154        counts,
8155        refresh,
8156        recovery,
8157        next_commands: graph_db_operator_next_commands(root, scope, false),
8158        warnings,
8159    })
8160}
8161
8162fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
8163    println!(
8164        "graph-db {} status: {} materialized: {}",
8165        report.operation, report.status, report.materialized
8166    );
8167    println!("graph_db: {}", report.graph_db);
8168    println!(
8169        "projection: version={} hash={} watermark={}",
8170        report
8171            .freshness
8172            .projection_version
8173            .as_deref()
8174            .unwrap_or("<missing>"),
8175        report
8176            .freshness
8177            .content_hash
8178            .as_deref()
8179            .unwrap_or("<missing>"),
8180        report
8181            .freshness
8182            .source_watermark
8183            .as_deref()
8184            .unwrap_or("<missing>")
8185    );
8186    println!(
8187        "rows: {} node(s), {} edge(s), {} tombstone(s)",
8188        report.counts.nodes, report.counts.edges, report.counts.tombstones.total
8189    );
8190    println!(
8191        "readiness: {} reason: {} fail_closed: {}",
8192        report.readiness.status, report.readiness.reason, report.readiness.fail_closed
8193    );
8194    if let Some(file_size) = report.counts.file_size_bytes {
8195        println!(
8196            "storage: {} byte(s), {} free byte(s)",
8197            file_size,
8198            report.counts.freelist_bytes.unwrap_or(0)
8199        );
8200    }
8201    if let Some(refresh) = &report.refresh {
8202        println!(
8203            "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
8204            refresh.tombstoned_nodes, refresh.tombstoned_edges
8205        );
8206        println!(
8207            "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)",
8208            refresh.upserted_nodes,
8209            refresh.upserted_edges,
8210            refresh.upserted_properties,
8211            refresh.unchanged_nodes,
8212            refresh.unchanged_edges,
8213            refresh.unchanged_properties,
8214            refresh.deleted_properties,
8215            refresh.pruned_tombstones
8216        );
8217    }
8218    println!(
8219        "compaction: {} tombstone_scan_rows={} live_rows={}",
8220        report.compaction.status,
8221        report.compaction.tombstone_scan_rows,
8222        report.compaction.live_rows
8223    );
8224    for proof in &report.compaction.proof {
8225        println!("compaction proof: {proof}");
8226    }
8227    if let Some(recovery) = report.recovery {
8228        println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
8229    }
8230    for diagnostic in &report.freshness.diagnostics {
8231        println!("diagnostic: {diagnostic}");
8232    }
8233    for diagnostic in &report.readiness.diagnostics {
8234        println!("readiness diagnostic: {diagnostic}");
8235    }
8236    for warning in &report.warnings {
8237        println!("warning: {warning}");
8238    }
8239    for command in &report.readiness.next_commands {
8240        println!("readiness next: {command}");
8241    }
8242    for command in &report.next_commands {
8243        println!("next: {command}");
8244    }
8245}
8246
8247pub(crate) fn print_graph_db_operator_report(
8248    report: &GraphDbOperatorReport,
8249    format: OutputFormat,
8250) -> Result<()> {
8251    if format.json_output {
8252        print_json_or_envelope(
8253            report,
8254            &format,
8255            "graph-db",
8256            &report.operation,
8257            ToolEnvelopeSummary {
8258                text: format!(
8259                    "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
8260                    report.operation,
8261                    report.status,
8262                    report.counts.nodes,
8263                    report.counts.edges,
8264                    report.counts.tombstones.total
8265                ),
8266                metrics: vec![
8267                    envelope_metric("operation", &report.operation),
8268                    envelope_metric("status", &report.status),
8269                    envelope_metric("nodes", report.counts.nodes),
8270                    envelope_metric("edges", report.counts.edges),
8271                    envelope_metric("tombstones", report.counts.tombstones.total),
8272                    envelope_metric("compaction", &report.compaction.status),
8273                    envelope_metric("readiness", &report.readiness.status),
8274                ],
8275            },
8276            false,
8277            report.next_commands.clone(),
8278        )
8279    } else {
8280        print_graph_db_operator_human(report);
8281        Ok(())
8282    }
8283}
8284
8285fn status_run_command_without_notes(run: &str) -> &str {
8286    run.split_once("  (")
8287        .map(|(command, _)| command)
8288        .unwrap_or(run)
8289}
8290
8291fn status_summarize_extract_command(run: &str) -> &str {
8292    let run = status_run_command_without_notes(run);
8293    run.split(" && ")
8294        .find(|command| command.contains("summarize --extract"))
8295        .unwrap_or(run)
8296}
8297
8298fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
8299    report
8300        .recommendations
8301        .run
8302        .as_deref()
8303        .filter(|command| command.contains("summarize --extract"))
8304        .map(status_summarize_extract_command)
8305        .unwrap_or("tsift summarize --extract .")
8306        .to_string()
8307}
8308
8309fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
8310    let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8311    readiness.diagnostics.push(format!(
8312        "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
8313    ));
8314    readiness
8315}
8316
8317fn graph_db_semantic_readiness(
8318    root: &Path,
8319    scope: Option<&str>,
8320    semantic_row_count: Option<usize>,
8321) -> GraphEffectivenessReadiness {
8322    if let Some(row_count) = semantic_row_count
8323        && row_count > 0
8324    {
8325        return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
8326    }
8327
8328    let report = match status::check_status(root) {
8329        Ok(report) => report,
8330        Err(err) => {
8331            return graph_effectiveness_blocked(
8332                "status_check_unavailable",
8333                vec![format!(
8334                    "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
8335                )],
8336                vec![graph_db_refresh_command(root, scope)],
8337            );
8338        }
8339    };
8340
8341    match &report.summaries {
8342        status::SummaryStatus::Available {
8343            cached_files,
8344            total_indexed_files,
8345            coverage_pct,
8346            ..
8347        } => {
8348            let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8349            readiness.diagnostics.push(format!(
8350                "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
8351            ));
8352            readiness
8353        }
8354        status::SummaryStatus::None { .. } => {
8355            let summarize = graph_db_status_summarize_command(&report);
8356            let index_command = report
8357                .recommendations
8358                .run
8359                .as_deref()
8360                .filter(|cmd| cmd.contains("index"))
8361                .map(str::to_string);
8362            let mut repair = Vec::new();
8363            if let Some(cmd) = index_command {
8364                repair.push(cmd);
8365            }
8366            repair.push(summarize.clone());
8367            repair.push(graph_db_refresh_command(root, scope));
8368            graph_effectiveness_blocked(
8369                "summary_cache_empty",
8370                vec![format!(
8371                    "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8372                    summarize,
8373                    root.display(),
8374                    graph_db_refresh_command(root, scope)
8375                )],
8376                repair,
8377            )
8378        }
8379        status::SummaryStatus::Unavailable => {
8380            let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
8381            let summarize = "tsift summarize --extract .".to_string();
8382            repair.push(summarize);
8383            repair.push(graph_db_refresh_command(root, scope));
8384            graph_effectiveness_blocked(
8385                "summary_cache_unavailable",
8386                vec![
8387                    "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(),
8388                ],
8389                repair,
8390            )
8391        }
8392    }
8393}
8394
8395pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
8396    let report = match status::check_status(root) {
8397        Ok(report) => report,
8398        Err(err) => {
8399            return vec![format!(
8400                "status check unavailable after graph-db refresh: {err:#}"
8401            )];
8402        }
8403    };
8404
8405    let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8406        Some(graph_db_status_summarize_command(&report))
8407    } else {
8408        None
8409    };
8410    let mut warnings = report.reminders;
8411    if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8412        let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
8413        warnings.push(format!(
8414            "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8415            run,
8416            root.display(),
8417            graph_db_refresh_command(root, scope)
8418        ));
8419    }
8420    dedupe_preserve_order(warnings)
8421}
8422
8423pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
8424    println!(
8425        "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
8426        report.applied, report.pruned_tombstones, report.reclaimed_bytes
8427    );
8428    println!("graph_db: {}", report.graph_db);
8429    println!(
8430        "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8431        report.counts_before.nodes,
8432        report.counts_before.edges,
8433        report.counts_before.tombstones.total,
8434        report.counts_before.file_size_bytes.unwrap_or(0),
8435        report.counts_before.freelist_bytes.unwrap_or(0)
8436    );
8437    println!(
8438        "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8439        report.counts_after.nodes,
8440        report.counts_after.edges,
8441        report.counts_after.tombstones.total,
8442        report.counts_after.file_size_bytes.unwrap_or(0),
8443        report.counts_after.freelist_bytes.unwrap_or(0)
8444    );
8445    for proof in &report.compaction_after.proof {
8446        println!("proof: {proof}");
8447    }
8448    for warning in &report.warnings {
8449        println!("warning: {warning}");
8450    }
8451    for command in &report.next_commands {
8452        println!("next: {command}");
8453    }
8454}
8455
8456fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
8457    raw.iter()
8458        .map(|value| {
8459            let (key, filter_value) = value
8460                .split_once('=')
8461                .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
8462            let key = key.trim();
8463            let filter_value = filter_value.trim();
8464            if key.is_empty() || filter_value.is_empty() {
8465                bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
8466            }
8467            Ok(GraphDbPropertyFilter {
8468                key: key.to_string(),
8469                value: filter_value.to_string(),
8470            })
8471        })
8472        .collect()
8473}
8474
8475fn graph_db_query_options(
8476    cursor: Option<String>,
8477    limit: Option<usize>,
8478    property_filters: &[String],
8479) -> Result<GraphDbQueryOptions> {
8480    Ok(GraphDbQueryOptions {
8481        cursor,
8482        limit: limit.filter(|limit| *limit > 0),
8483        property_filters: parse_graph_db_property_filters(property_filters)?,
8484    })
8485}
8486
8487fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
8488    GraphQueryOptions {
8489        cursor: options.cursor.clone(),
8490        limit: options.limit,
8491        property_filters: options
8492            .property_filters
8493            .iter()
8494            .map(|filter| GraphPropertyFilter {
8495                key: filter.key.clone(),
8496                value: filter.value.clone(),
8497            })
8498            .collect(),
8499    }
8500}
8501
8502fn graph_db_page_report_from_store(
8503    page: GraphQueryPage,
8504    property_filters: Vec<GraphDbPropertyFilter>,
8505) -> GraphDbPageReport {
8506    GraphDbPageReport {
8507        cursor: page.cursor,
8508        limit: page.limit,
8509        next_cursor: page.next_cursor,
8510        returned_nodes: page.returned_nodes,
8511        returned_edges: page.returned_edges,
8512        truncated: page.truncated,
8513        property_filters,
8514        diagnostics: page.diagnostics,
8515    }
8516}
8517
8518fn graph_db_neighborhood_ranking_gate(
8519    ranked_neighbor_cap: usize,
8520) -> GraphDbNeighborhoodRankingGate {
8521    GraphDbNeighborhoodRankingGate {
8522        status: "held_default_order_unchanged".to_string(),
8523        ranked_output_default: false,
8524        default_order: "stable_node_id".to_string(),
8525        default_change_gate: "community_search_quality_metrics".to_string(),
8526        required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
8527            .iter()
8528            .map(|workload| (*workload).to_string())
8529            .collect(),
8530        required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
8531            .iter()
8532            .map(|metric| (*metric).to_string())
8533            .collect(),
8534        max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
8535        min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
8536        min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
8537        min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
8538        diagnostics: vec![
8539            "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
8540            format!(
8541                "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
8542            ),
8543            "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
8544        ],
8545    }
8546}
8547
8548fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
8549    match limit {
8550        Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
8551        Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
8552    }
8553}
8554
8555fn graph_db_ranked_neighbors(
8556    center_id: &str,
8557    nodes: &[SubstrateGraphNode],
8558    edges: &[SubstrateGraphEdge],
8559    cap: usize,
8560) -> Vec<GraphDbRankedNeighbor> {
8561    resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
8562}
8563
8564fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
8565    center_id: &str,
8566    depth: usize,
8567    edge_kind: Option<&str>,
8568    limit: Option<usize>,
8569    unranked_nodes: &[SubstrateGraphNode],
8570    unranked_edges: &[SubstrateGraphEdge],
8571    store: &S,
8572) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
8573    use std::time::Instant;
8574    let max_nodes = match limit {
8575        Some(0) | None => 200,
8576        Some(n) => n.clamp(10, 500),
8577    };
8578    let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
8579        .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
8580    if let Some(kind) = edge_kind {
8581        options = options.with_edge_kind(kind);
8582    }
8583    let start = Instant::now();
8584    let result = store.ranked_neighborhood(center_id, &options)?;
8585    let latency = start.elapsed().as_micros();
8586    let Some(ranked) = result else {
8587        return Ok(None);
8588    };
8589    let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8590    let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8591    let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8592    let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8593        0.0
8594    } else {
8595        (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8596    };
8597    let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8598        let mut name_count = BTreeMap::<&str, usize>::new();
8599        for n in nodes {
8600            *name_count.entry(&n.label).or_default() += 1;
8601        }
8602        name_count.values().filter(|&&c| c > 1).count()
8603    };
8604    let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8605        if nodes.is_empty() {
8606            return 100.0;
8607        }
8608        let with_handle = nodes
8609            .iter()
8610            .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8611            .count();
8612        (with_handle as f64 / nodes.len() as f64) * 100.0
8613    };
8614    let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8615        if nodes.is_empty() {
8616            return 0.0;
8617        }
8618        let semantic_kinds = [
8619            "semantic_concept",
8620            "semantic_entity",
8621            "symbol",
8622            "file",
8623            "source_handle",
8624        ];
8625        let useful = nodes
8626            .iter()
8627            .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8628            .count();
8629        let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8630        let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8631        (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8632            / nodes.len() as f64
8633    };
8634    let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8635        let edge_pairs: Vec<(String, String)> = ranked
8636            .edges
8637            .iter()
8638            .map(|e| (e.from_id.clone(), e.to_id.clone()))
8639            .collect();
8640        let cr = tsift_graph::detect_communities(&edge_pairs);
8641        let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8642        let mut fully_kept = 0usize;
8643        let mut partially_pruned = 0usize;
8644        let mut fully_pruned = 0usize;
8645        let mut pruned_kinds = BTreeSet::new();
8646        let mut pruned_labels = Vec::new();
8647        for comm in &cr.communities {
8648            let kept_in_comm: Vec<&str> = comm
8649                .members
8650                .iter()
8651                .filter(|m| kept_labels.contains(m.name.as_str()))
8652                .map(|m| m.name.as_str())
8653                .collect();
8654            if kept_in_comm.len() == comm.members.len() {
8655                fully_kept += 1;
8656            } else if kept_in_comm.is_empty() {
8657                fully_pruned += 1;
8658                for m in &comm.members {
8659                    if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8660                        pruned_kinds.insert(n.kind.clone());
8661                    }
8662                    pruned_labels.push(m.name.clone());
8663                }
8664            } else {
8665                partially_pruned += 1;
8666            }
8667        }
8668        pruned_labels.truncate(5);
8669        Some(CommunityTruncationSummary {
8670            total_communities: cr.communities.len(),
8671            fully_kept,
8672            partially_pruned,
8673            fully_pruned,
8674            pruned_community_kinds: pruned_kinds.into_iter().collect(),
8675            pruned_community_top_labels: pruned_labels,
8676        })
8677    } else {
8678        None
8679    };
8680    Ok(Some(GraphDbRankedNeighborhoodComparison {
8681        traversal_nodes: ranked.nodes.len(),
8682        traversal_edges: ranked.edges.len(),
8683        pruned_count: ranked.pruned_count,
8684        total_discovered: ranked.total_discovered,
8685        latency_micros: latency,
8686        overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8687        useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8688            / 1000.0,
8689        useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8690            .round()
8691            / 1000.0,
8692        duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8693        duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8694        handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8695        handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8696            / 100.0,
8697        community_truncation_summary,
8698        diagnostics: vec![
8699            format!(
8700                "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8701                ranked.nodes.len(),
8702                ranked.edges.len(),
8703                ranked.pruned_count,
8704                ranked.total_discovered,
8705                latency
8706            ),
8707            format!(
8708                "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8709                overlap_pct,
8710                overlap_count,
8711                unranked_ids.len(),
8712                ranked_ids.len()
8713            ),
8714            "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8715        ],
8716    }))
8717}
8718
8719struct GraphDbBudgetedSubgraph {
8720    nodes: Vec<SubstrateGraphNode>,
8721    edges: Vec<SubstrateGraphEdge>,
8722    report: GraphDbOutputBudgetReport,
8723    truncated: bool,
8724    next_cursor: Option<String>,
8725}
8726
8727const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8728const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8729const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8730
8731fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8732    match limit {
8733        Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8734        Some(limit) => limit
8735            .saturating_mul(320)
8736            .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8737    }
8738}
8739
8740fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8741    if matches!(limit, Some(0) | None) {
8742        return match kind {
8743            "source_handle" => 10,
8744            "worker_context" | "worker_result" => 8,
8745            "semantic_concept" | "semantic_entity" => 10,
8746            "file" | "symbol" | "route" => 12,
8747            _ => 8,
8748        };
8749    }
8750    let base = limit.unwrap_or(0).max(1);
8751    match kind {
8752        "source_handle" => base.saturating_add(4),
8753        "worker_context" | "worker_result" => base.saturating_add(2),
8754        "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8755        "file" | "symbol" | "route" => base.saturating_add(4),
8756        _ => base.saturating_add(1),
8757    }
8758}
8759
8760fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8761    if matches!(limit, Some(0) | None) {
8762        return match kind {
8763            "mentions" | "mentions_concept" | "mentions_entity" => 24,
8764            "semantic_relation" | "calls" | "defines" => 20,
8765            _ => 16,
8766        };
8767    }
8768    let base = limit.unwrap_or(0).max(1);
8769    match kind {
8770        "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8771        "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8772        _ => base.saturating_add(2),
8773    }
8774}
8775
8776fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8777    serde_json::to_vec(value)
8778        .map(|bytes| bytes.len().div_ceil(4).max(1))
8779        .unwrap_or(1)
8780}
8781
8782fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8783    let mut parts = vec![node.kind.clone(), node.label.clone()];
8784    for key in [
8785        "detail",
8786        "description",
8787        "source_ref",
8788        "path",
8789        "source_file",
8790        "source_symbol",
8791        "text_preview",
8792    ] {
8793        if let Some(value) = node.properties.get(key) {
8794            parts.push(value.clone());
8795        }
8796    }
8797    parts.join(" ")
8798}
8799
8800fn graph_db_semantic_scores_for_query(
8801    query: Option<&str>,
8802    nodes: &[SubstrateGraphNode],
8803) -> BTreeMap<String, f64> {
8804    let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8805        return BTreeMap::new();
8806    };
8807    let query_embedding = semantic_embedding(query);
8808    nodes
8809        .iter()
8810        .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8811        .filter_map(|node| {
8812            let embedding = node
8813                .properties
8814                .get("embedding")
8815                .and_then(|value| parse_semantic_embedding_property(value))?;
8816            Some((
8817                node.id.clone(),
8818                semantic_cosine(&query_embedding, &embedding),
8819            ))
8820        })
8821        .collect()
8822}
8823
8824fn graph_db_depth_by_id(
8825    origin_ids: &[String],
8826    edges: &[SubstrateGraphEdge],
8827) -> BTreeMap<String, usize> {
8828    let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8829    for edge in edges {
8830        adjacency
8831            .entry(edge.from_id.clone())
8832            .or_default()
8833            .push(edge.to_id.clone());
8834        adjacency
8835            .entry(edge.to_id.clone())
8836            .or_default()
8837            .push(edge.from_id.clone());
8838    }
8839
8840    let mut depth_by_id = BTreeMap::<String, usize>::new();
8841    let mut queue = VecDeque::<String>::new();
8842    for origin in origin_ids {
8843        if depth_by_id.insert(origin.clone(), 0).is_none() {
8844            queue.push_back(origin.clone());
8845        }
8846    }
8847    while let Some(current) = queue.pop_front() {
8848        let depth = depth_by_id.get(&current).copied().unwrap_or(0);
8849        for next in adjacency.get(&current).into_iter().flatten() {
8850            if depth_by_id.contains_key(next) {
8851                continue;
8852            }
8853            depth_by_id.insert(next.clone(), depth.saturating_add(1));
8854            queue.push_back(next.clone());
8855        }
8856    }
8857    depth_by_id
8858}
8859
8860fn graph_db_source_covered_ids(
8861    nodes: &[SubstrateGraphNode],
8862    edges: &[SubstrateGraphEdge],
8863) -> BTreeSet<String> {
8864    let source_ids = nodes
8865        .iter()
8866        .filter(|node| node.kind == "source_handle")
8867        .map(|node| node.id.as_str())
8868        .collect::<BTreeSet<_>>();
8869    let mut covered = source_ids
8870        .iter()
8871        .map(|id| (*id).to_string())
8872        .collect::<BTreeSet<_>>();
8873    for edge in edges {
8874        if source_ids.contains(edge.from_id.as_str()) {
8875            covered.insert(edge.to_id.clone());
8876        }
8877        if source_ids.contains(edge.to_id.as_str()) {
8878            covered.insert(edge.from_id.clone());
8879        }
8880    }
8881    covered
8882}
8883
8884fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8885    for key in [
8886        "observed_at_unix",
8887        "completed_at_unix",
8888        "created_at_unix",
8889        "started_at_unix",
8890    ] {
8891        if let Some(value) = node.properties.get(key)
8892            && let Ok(epoch) = value.parse::<i64>()
8893        {
8894            return epoch.div_euclid(86_400).clamp(0, 40_000);
8895        }
8896    }
8897    0
8898}
8899
8900fn graph_db_node_kind_score(kind: &str) -> i64 {
8901    match kind {
8902        "source_handle" => 180,
8903        "worker_context" => 170,
8904        "worker_result" => 160,
8905        "semantic_concept" | "semantic_entity" => 150,
8906        "backlog" | "job_packet" => 130,
8907        "symbol" => 120,
8908        "file" => 110,
8909        "route" => 105,
8910        "session" => 90,
8911        _ => 40,
8912    }
8913}
8914
8915fn graph_db_edge_kind_score(kind: &str) -> i64 {
8916    match kind {
8917        "mentions_concept" | "mentions_entity" => 180,
8918        "semantic_relation" => 170,
8919        "mentions" => 165,
8920        "requests_context" | "scopes_context" | "scopes_source" => 155,
8921        "explains_result" => 150,
8922        "calls" => 145,
8923        "defines" | "handled_by" | "defines_route" => 130,
8924        "contains" | "targets" => 120,
8925        "records_memory_source" | "has_vector_handle" => 115,
8926        _ => 40,
8927    }
8928}
8929
8930fn graph_db_node_usefulness_score(
8931    node: &SubstrateGraphNode,
8932    depth_by_id: &BTreeMap<String, usize>,
8933    semantic_scores: &BTreeMap<String, f64>,
8934    source_covered_ids: &BTreeSet<String>,
8935    origin_ids: &[String],
8936) -> i64 {
8937    if origin_ids.iter().any(|origin| origin == &node.id) {
8938        return 1_000_000;
8939    }
8940    let semantic = semantic_scores
8941        .get(&node.id)
8942        .map(|score| (score.max(0.0) * 1_000.0) as i64)
8943        .unwrap_or(0);
8944    let depth_penalty = depth_by_id
8945        .get(&node.id)
8946        .map(|depth| (*depth as i64).saturating_mul(55))
8947        .unwrap_or(180);
8948    let source_coverage = if source_covered_ids.contains(&node.id)
8949        || node.properties.contains_key("source_ref")
8950        || node.properties.contains_key("path")
8951    {
8952        120
8953    } else {
8954        0
8955    };
8956    graph_db_node_kind_score(&node.kind)
8957        + semantic
8958        + source_coverage
8959        + graph_db_recency_score(node).min(80)
8960        - depth_penalty
8961}
8962
8963fn graph_db_edge_usefulness_score(
8964    edge: &SubstrateGraphEdge,
8965    node_score_by_id: &BTreeMap<String, i64>,
8966    depth_by_id: &BTreeMap<String, usize>,
8967) -> i64 {
8968    let endpoint_score = node_score_by_id
8969        .get(&edge.from_id)
8970        .copied()
8971        .unwrap_or_default()
8972        .max(
8973            node_score_by_id
8974                .get(&edge.to_id)
8975                .copied()
8976                .unwrap_or_default(),
8977        );
8978    let depth_penalty = depth_by_id
8979        .get(&edge.from_id)
8980        .into_iter()
8981        .chain(depth_by_id.get(&edge.to_id))
8982        .min()
8983        .map(|depth| (*depth as i64).saturating_mul(35))
8984        .unwrap_or(140);
8985    graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8986}
8987
8988fn graph_db_push_drop(
8989    drops: &mut BTreeMap<(String, String, String), usize>,
8990    item: &str,
8991    kind: &str,
8992    reason: &str,
8993) {
8994    *drops
8995        .entry((item.to_string(), kind.to_string(), reason.to_string()))
8996        .or_default() += 1;
8997}
8998
8999fn graph_db_budget_drop_report(
9000    drops: BTreeMap<(String, String, String), usize>,
9001) -> Vec<GraphDbDroppedByBudget> {
9002    drops
9003        .into_iter()
9004        .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
9005            item,
9006            kind,
9007            reason,
9008            dropped,
9009        })
9010        .collect()
9011}
9012
9013fn graph_db_apply_output_budget(
9014    origin_ids: &[String],
9015    semantic_scores: &BTreeMap<String, f64>,
9016    nodes: Vec<SubstrateGraphNode>,
9017    edges: Vec<SubstrateGraphEdge>,
9018    limit: Option<usize>,
9019) -> GraphDbBudgetedSubgraph {
9020    graph_db_apply_output_budget_with_depths_and_cursor(
9021        origin_ids,
9022        semantic_scores,
9023        nodes,
9024        edges,
9025        limit,
9026        None,
9027        None,
9028    )
9029}
9030
9031fn graph_db_apply_output_budget_with_depths_and_cursor(
9032    origin_ids: &[String],
9033    semantic_scores: &BTreeMap<String, f64>,
9034    nodes: Vec<SubstrateGraphNode>,
9035    edges: Vec<SubstrateGraphEdge>,
9036    limit: Option<usize>,
9037    depth_overrides: Option<&BTreeMap<String, usize>>,
9038    cursor: Option<&str>,
9039) -> GraphDbBudgetedSubgraph {
9040    let max_tokens = graph_db_output_token_cap(limit);
9041    let candidate_nodes = nodes.len();
9042    let candidate_edges = edges.len();
9043    let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
9044    if let Some(depth_overrides) = depth_overrides {
9045        for (id, depth) in depth_overrides {
9046            depth_by_id
9047                .entry(id.clone())
9048                .and_modify(|current| *current = (*current).min(*depth))
9049                .or_insert(*depth);
9050        }
9051    }
9052    let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
9053    let node_score_by_id = nodes
9054        .iter()
9055        .map(|node| {
9056            (
9057                node.id.clone(),
9058                graph_db_node_usefulness_score(
9059                    node,
9060                    &depth_by_id,
9061                    semantic_scores,
9062                    &source_covered_ids,
9063                    origin_ids,
9064                ),
9065            )
9066        })
9067        .collect::<BTreeMap<_, _>>();
9068
9069    let mut node_candidates = nodes.iter().collect::<Vec<_>>();
9070    node_candidates.sort_by(|left, right| {
9071        node_score_by_id
9072            .get(&right.id)
9073            .cmp(&node_score_by_id.get(&left.id))
9074            .then_with(|| left.kind.cmp(&right.kind))
9075            .then_with(|| left.label.cmp(&right.label))
9076            .then_with(|| left.id.cmp(&right.id))
9077    });
9078
9079    let cursor_skip = if let Some(cursor) = cursor {
9080        node_candidates
9081            .iter()
9082            .position(|node| node.id == cursor)
9083            .map(|pos| pos.saturating_add(1))
9084            .unwrap_or(0)
9085    } else {
9086        0
9087    };
9088    if cursor_skip > 0 {
9089        node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
9090    }
9091
9092    let mut selected_node_ids = BTreeSet::new();
9093    let mut selected_node_counts = BTreeMap::<String, usize>::new();
9094    let mut estimated_tokens = 0usize;
9095    let mut drops = BTreeMap::<(String, String, String), usize>::new();
9096    for node in &node_candidates {
9097        let kind_count = selected_node_counts
9098            .get(&node.kind)
9099            .copied()
9100            .unwrap_or_default();
9101        if !origin_ids.iter().any(|origin| origin == &node.id)
9102            && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
9103        {
9104            graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
9105            continue;
9106        }
9107        let tokens = graph_db_estimated_tokens(node);
9108        if !origin_ids.iter().any(|origin| origin == &node.id)
9109            && estimated_tokens.saturating_add(tokens) > max_tokens
9110        {
9111            graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
9112            continue;
9113        }
9114        selected_node_ids.insert(node.id.clone());
9115        *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
9116        estimated_tokens = estimated_tokens.saturating_add(tokens);
9117    }
9118
9119    let has_remaining_candidates = node_candidates
9120        .iter()
9121        .any(|node| !selected_node_ids.contains(&node.id));
9122
9123    let mut selected_nodes = nodes
9124        .into_iter()
9125        .filter(|node| selected_node_ids.contains(&node.id))
9126        .collect::<Vec<_>>();
9127
9128    let mut edge_candidates = edges
9129        .iter()
9130        .filter(|edge| {
9131            selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
9132        })
9133        .collect::<Vec<_>>();
9134    let edge_score_by_key = edge_candidates
9135        .iter()
9136        .map(|edge| {
9137            (
9138                graph_db_edge_key(edge),
9139                graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
9140            )
9141        })
9142        .collect::<BTreeMap<_, _>>();
9143    edge_candidates.sort_by(|left, right| {
9144        edge_score_by_key
9145            .get(&graph_db_edge_key(right))
9146            .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
9147            .then_with(|| left.kind.cmp(&right.kind))
9148            .then_with(|| left.from_id.cmp(&right.from_id))
9149            .then_with(|| left.to_id.cmp(&right.to_id))
9150    });
9151
9152    let endpoint_dropped_edges = edges
9153        .iter()
9154        .filter(|edge| {
9155            !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
9156        })
9157        .count();
9158    if endpoint_dropped_edges > 0 {
9159        drops.insert(
9160            (
9161                "edge".to_string(),
9162                "*".to_string(),
9163                "endpoint_node_dropped".to_string(),
9164            ),
9165            endpoint_dropped_edges,
9166        );
9167    }
9168
9169    let mut selected_edge_ids = BTreeSet::new();
9170    let mut selected_edge_counts = BTreeMap::<String, usize>::new();
9171    for edge in edge_candidates {
9172        let kind_count = selected_edge_counts
9173            .get(&edge.kind)
9174            .copied()
9175            .unwrap_or_default();
9176        if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
9177            graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
9178            continue;
9179        }
9180        let tokens = graph_db_estimated_tokens(edge);
9181        if estimated_tokens.saturating_add(tokens) > max_tokens {
9182            graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
9183            continue;
9184        }
9185        selected_edge_ids.insert(graph_db_edge_key(edge));
9186        *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
9187        estimated_tokens = estimated_tokens.saturating_add(tokens);
9188    }
9189
9190    let selected_edges = edges
9191        .into_iter()
9192        .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
9193        .collect::<Vec<_>>();
9194    let dropped_by_budget = graph_db_budget_drop_report(drops);
9195    let truncated = has_remaining_candidates;
9196    let next_cursor = if truncated {
9197        selected_nodes.last().map(|node| node.id.clone())
9198    } else {
9199        None
9200    };
9201    let mut diagnostics = vec![
9202        "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
9203            .to_string(),
9204        format!(
9205            "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
9206            selected_nodes.len(),
9207            candidate_nodes,
9208            selected_edges.len(),
9209            candidate_edges,
9210            max_tokens
9211        ),
9212    ];
9213    if cursor.is_some() {
9214        diagnostics.push(format!(
9215            "cursor skipped {} previously returned candidate(s)",
9216            cursor_skip
9217        ));
9218    }
9219    if next_cursor.is_some() {
9220        diagnostics.push(
9221            "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
9222        );
9223    }
9224    selected_nodes.shrink_to_fit();
9225
9226    GraphDbBudgetedSubgraph {
9227        nodes: selected_nodes,
9228        edges: selected_edges,
9229        report: GraphDbOutputBudgetReport {
9230            max_tokens,
9231            estimated_tokens,
9232            selected_nodes: selected_node_ids.len(),
9233            selected_edges: selected_edge_ids.len(),
9234            candidate_nodes,
9235            candidate_edges,
9236            dropped_by_budget,
9237            diagnostics,
9238        },
9239        truncated,
9240        next_cursor,
9241    }
9242}
9243
9244fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
9245    if edge.id.is_empty() {
9246        substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
9247    } else {
9248        edge.id.clone()
9249    }
9250}
9251
9252fn graph_db_schema() -> GraphDbSchema {
9253    GraphDbSchema {
9254        contract_versions: vec![
9255            GraphDbSchemaContract {
9256                name: "graph_db_evidence",
9257                version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9258                description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
9259            },
9260            GraphDbSchemaContract {
9261                name: "worker_prompt_packet",
9262                version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
9263                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",
9264            },
9265            GraphDbSchemaContract {
9266                name: "conflict_matrix",
9267                version: CONFLICT_MATRIX_CONTRACT_VERSION,
9268                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",
9269            },
9270            GraphDbSchemaContract {
9271                name: "context_pack_graph_orchestration",
9272                version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
9273                description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
9274            },
9275            GraphDbSchemaContract {
9276                name: "session_review_follow_up",
9277                version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
9278                description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
9279            },
9280            GraphDbSchemaContract {
9281                name: "dispatch_trace",
9282                version: DISPATCH_TRACE_CONTRACT_VERSION,
9283                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",
9284            },
9285            GraphDbSchemaContract {
9286                name: "dependency_dag",
9287                version: DEPENDENCY_DAG_CONTRACT_VERSION,
9288                description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
9289            },
9290        ],
9291        node_fields: vec![
9292            GraphDbSchemaField {
9293                name: "id",
9294                value_type: "string",
9295                description: "Stable provider-neutral node id",
9296            },
9297            GraphDbSchemaField {
9298                name: "kind",
9299                value_type: "string",
9300                description: "Application-defined node family such as file, symbol, or backlog",
9301            },
9302            GraphDbSchemaField {
9303                name: "label",
9304                value_type: "string",
9305                description: "Human-readable label",
9306            },
9307            GraphDbSchemaField {
9308                name: "properties",
9309                value_type: "object<string,string>",
9310                description: "Adapter-specific string properties",
9311            },
9312            GraphDbSchemaField {
9313                name: "provenance",
9314                value_type: "array",
9315                description: "Source system and source reference metadata",
9316            },
9317            GraphDbSchemaField {
9318                name: "freshness",
9319                value_type: "object|null",
9320                description: "Optional content hash and observed timestamp",
9321            },
9322        ],
9323        edge_fields: vec![
9324            GraphDbSchemaField {
9325                name: "id",
9326                value_type: "string",
9327                description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
9328            },
9329            GraphDbSchemaField {
9330                name: "from_id",
9331                value_type: "string",
9332                description: "Source node id",
9333            },
9334            GraphDbSchemaField {
9335                name: "to_id",
9336                value_type: "string",
9337                description: "Target node id",
9338            },
9339            GraphDbSchemaField {
9340                name: "kind",
9341                value_type: "string",
9342                description: "Application-defined edge relation",
9343            },
9344            GraphDbSchemaField {
9345                name: "properties",
9346                value_type: "object<string,string>",
9347                description: "Adapter-specific string properties",
9348            },
9349            GraphDbSchemaField {
9350                name: "provenance",
9351                value_type: "array",
9352                description: "Source system and source reference metadata",
9353            },
9354            GraphDbSchemaField {
9355                name: "freshness",
9356                value_type: "object|null",
9357                description: "Optional content hash and observed timestamp",
9358            },
9359        ],
9360        operations: vec![
9361            GraphDbSchemaOperation {
9362                command: "refresh",
9363                description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
9364            },
9365            GraphDbSchemaOperation {
9366                command: "status",
9367                description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
9368            },
9369            GraphDbSchemaOperation {
9370                command: "doctor",
9371                description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
9372            },
9373            GraphDbSchemaOperation {
9374                command: "drift",
9375                description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
9376            },
9377            GraphDbSchemaOperation {
9378                command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
9379                description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
9380            },
9381            GraphDbSchemaOperation {
9382                command: "snapshot-export <output.db.gz> [--force]",
9383                description: "Export the current SQLite graph.db as a gzip-compressed shareable artifact only after freshness, doctor, WAL, and sidecar checks pass",
9384            },
9385            GraphDbSchemaOperation {
9386                command: "snapshot-import <artifact.db.gz> [--replace]",
9387                description: "Stage and validate a compressed SQLite graph.db artifact through doctor and freshness checks before replacing the local graph.db",
9388            },
9389            GraphDbSchemaOperation {
9390                command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
9391                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",
9392            },
9393            GraphDbSchemaOperation {
9394                command: "evidence <target> [--depth N] [--limit N]",
9395                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",
9396            },
9397            GraphDbSchemaOperation {
9398                command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
9399                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",
9400            },
9401            GraphDbSchemaOperation {
9402                command: "dispatch-trace [target...] --path <session> [--format json|html]",
9403                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",
9404            },
9405            GraphDbSchemaOperation {
9406                command: "dependency-dag [target...] --path <session>",
9407                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",
9408            },
9409            GraphDbSchemaOperation {
9410                command: "schema",
9411                description: "Return record and operation schemas",
9412            },
9413            GraphDbSchemaOperation {
9414                command: "node <id>",
9415                description: "Return one node by stable id",
9416            },
9417            GraphDbSchemaOperation {
9418                command: "edge <id>",
9419                description: "Return one edge by stable edge id",
9420            },
9421            GraphDbSchemaOperation {
9422                command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9423                description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
9424            },
9425            GraphDbSchemaOperation {
9426                command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9427                description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
9428            },
9429            GraphDbSchemaOperation {
9430                command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
9431                description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
9432            },
9433            GraphDbSchemaOperation {
9434                command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
9435                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",
9436            },
9437            GraphDbSchemaOperation {
9438                command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
9439                description: "Return the shortest directed path by node id, optionally bounded by hop count",
9440            },
9441        ],
9442    }
9443}
9444
9445pub(crate) fn sqlite_graph_freshness(
9446    store: &SqliteGraphStore,
9447    scope: &str,
9448) -> Result<GraphDbFreshnessReport> {
9449    let version = store.projection_version(scope)?;
9450    let Some(version) = version else {
9451        return Ok(GraphDbFreshnessReport {
9452            status: "missing".to_string(),
9453            fail_closed: true,
9454            projection_version: None,
9455            content_hash: None,
9456            source_watermark: None,
9457            diagnostics: vec![
9458                "graph projection metadata is missing; rebuild the graph before trusting reads"
9459                    .to_string(),
9460            ],
9461        });
9462    };
9463    let mut diagnostics = Vec::new();
9464    let fail_closed =
9465        version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
9466    if version.projection_version != GRAPH_PROJECTION_VERSION {
9467        diagnostics.push(format!(
9468            "projection version mismatch: expected {} got {}",
9469            GRAPH_PROJECTION_VERSION, version.projection_version
9470        ));
9471    }
9472    if version.content_hash.is_none() {
9473        diagnostics.push("projection content hash is missing".to_string());
9474    }
9475    Ok(GraphDbFreshnessReport {
9476        status: if fail_closed { "stale" } else { "current" }.to_string(),
9477        fail_closed,
9478        projection_version: Some(version.projection_version),
9479        content_hash: version.content_hash,
9480        source_watermark: version.source_watermark,
9481        diagnostics,
9482    })
9483}
9484
9485pub(crate) fn convex_graph_freshness(
9486    local: &ConvexProjectionRows,
9487    snapshot: &ConvexProjectionRows,
9488    scope: Option<&str>,
9489) -> GraphDbFreshnessReport {
9490    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
9491    GraphDbFreshnessReport {
9492        status: freshness.status,
9493        fail_closed: freshness.fail_closed,
9494        projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
9495        content_hash: freshness.snapshot_hash,
9496        source_watermark: None,
9497        diagnostics: freshness.diagnostics,
9498    }
9499}
9500
9501pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
9502    let (nodes, edges) = store.graph_counts()?;
9503    let files = store.file_count()?;
9504    Ok(GraphDbFreshnessReport {
9505        status: "current".to_string(),
9506        fail_closed: false,
9507        projection_version: Some("tokensave-readonly".to_string()),
9508        content_hash: None,
9509        source_watermark: Some(store.db_path().to_string_lossy().to_string()),
9510        diagnostics: vec![format!(
9511            "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
9512            nodes, edges, files
9513        )],
9514    })
9515}
9516
9517pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
9518    match TokensaveDb::discover(root) {
9519        Ok(Some(store)) => {
9520            report.push_check(GraphDbDoctorCheck {
9521                name: "tokensave_db_open".to_string(),
9522                status: "ok".to_string(),
9523                fail_closed: false,
9524                diagnostics: vec![format!(
9525                    "opened tokensave database at {}",
9526                    store.db_path().display()
9527                )],
9528                repair_commands: Vec::new(),
9529            });
9530            match (store.node_count(), store.edge_count(), store.file_count()) {
9531                (Ok(nodes), Ok(edges), Ok(files)) => {
9532                    report.push_check(GraphDbDoctorCheck {
9533                        name: "tokensave_counts".to_string(),
9534                        status: "ok".to_string(),
9535                        fail_closed: false,
9536                        diagnostics: vec![format!(
9537                            "tokensave contains {} node(s), {} edge(s), {} file(s)",
9538                            nodes, edges, files
9539                        )],
9540                        repair_commands: Vec::new(),
9541                    });
9542                }
9543                (nodes, edges, files) => {
9544                    report.push_check(graph_db_doctor_check(
9545                        "tokensave_counts",
9546                        vec![format!(
9547                            "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
9548                            nodes.err(),
9549                            edges.err(),
9550                            files.err()
9551                        )],
9552                        Vec::new(),
9553                    ));
9554                }
9555            }
9556        }
9557        Ok(None) => report.push_check(graph_db_doctor_check(
9558            "tokensave_db_exists",
9559            vec![format!(
9560                "tokensave database is missing at {}",
9561                root.join(".tokensave").join("tokensave.db").display()
9562            )],
9563            Vec::new(),
9564        )),
9565        Err(err) => report.push_check(graph_db_doctor_check(
9566            "tokensave_db_open",
9567            vec![err.to_string()],
9568            Vec::new(),
9569        )),
9570    }
9571}
9572
9573const GRAPH_DB_EVIDENCE_TARGET_KINDS: &[&str] = &[
9574    "backlog",
9575    "job_packet",
9576    "worker_result",
9577    "worker_context",
9578    "source_handle",
9579];
9580
9581pub(crate) fn graph_db_evidence_preferred_path(root: &Path, path_hint: &Path) -> Option<String> {
9582    hinted_markdown_file(root, path_hint).map(|path| {
9583        relativize_pathbuf(&path, root)
9584            .to_string_lossy()
9585            .replace('\\', "/")
9586    })
9587}
9588
9589fn graph_db_ambiguous_target_message(
9590    target: &str,
9591    kind: &str,
9592    candidates: &[SubstrateGraphNode],
9593) -> String {
9594    let mut by_path = BTreeMap::<String, String>::new();
9595    for candidate in candidates.iter().filter(|node| node.kind == kind) {
9596        let path = candidate
9597            .properties
9598            .get("path")
9599            .cloned()
9600            .unwrap_or_else(|| "<no path>".to_string());
9601        by_path.entry(path).or_insert_with(|| candidate.id.clone());
9602    }
9603    let examples = by_path
9604        .iter()
9605        .take(5)
9606        .map(|(path, node_id)| format!("{node_id} path={path}"))
9607        .collect::<Vec<_>>()
9608        .join(", ");
9609    format!(
9610        "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",
9611        by_path.len()
9612    )
9613}
9614
9615pub(crate) fn graph_db_resolve_evidence_target_with_path(
9616    store: &impl GraphStore,
9617    target: &str,
9618    preferred_path: Option<&str>,
9619) -> Result<Option<SubstrateGraphNode>> {
9620    if let Some(node) = store.node(target)? {
9621        return Ok(Some(node));
9622    }
9623    let candidates =
9624        store.evidence_target_candidates(target, GRAPH_DB_EVIDENCE_TARGET_KINDS, preferred_path)?;
9625    if candidates.is_empty() {
9626        return Ok(None);
9627    }
9628    if preferred_path.is_none() {
9629        let first_kind = candidates[0].kind.as_str();
9630        let distinct_paths = candidates
9631            .iter()
9632            .filter(|node| node.kind == first_kind)
9633            .map(|node| {
9634                node.properties
9635                    .get("path")
9636                    .map(String::as_str)
9637                    .unwrap_or("")
9638            })
9639            .collect::<BTreeSet<_>>();
9640        if distinct_paths.len() > 1 {
9641            bail!(
9642                "{}",
9643                graph_db_ambiguous_target_message(target, first_kind, &candidates)
9644            );
9645        }
9646    }
9647    Ok(candidates.into_iter().next())
9648}
9649
9650pub(crate) fn graph_db_resolve_evidence_target(
9651    store: &impl GraphStore,
9652    target: &str,
9653) -> Result<Option<SubstrateGraphNode>> {
9654    graph_db_resolve_evidence_target_with_path(store, target, None)
9655}
9656
9657fn graph_db_reachable_nodes_by_kind(
9658    store: &impl GraphStore,
9659    from_id: &str,
9660    kind: &str,
9661    depth: usize,
9662    limit: usize,
9663) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9664    store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9665}
9666
9667fn graph_db_evidence_completed_queue_drift_warnings(
9668    store: &impl GraphStore,
9669    target: &SubstrateGraphNode,
9670    worker_results: &[SubstrateGraphNode],
9671) -> Result<Vec<String>> {
9672    let ref_id = target.properties.get("ref_id").map(String::as_str);
9673    let has_completed_result = worker_results.iter().any(|node| {
9674        node.properties.get("status").map(String::as_str) == Some("completed")
9675            && node.properties.get("ref_id").map(String::as_str) == ref_id
9676    });
9677    if !has_completed_result {
9678        return Ok(Vec::new());
9679    }
9680    let active_jobs = store
9681        .nodes_by_kind("job_packet")?
9682        .into_iter()
9683        .filter(|node| {
9684            node.properties.get("ref_id").map(String::as_str) == ref_id
9685                && node.label.starts_with("do #")
9686        })
9687        .collect::<Vec<_>>();
9688    if active_jobs.is_empty() {
9689        return Ok(Vec::new());
9690    }
9691    let repair = match (target.properties.get("path"), ref_id) {
9692        (Some(path), Some(id)) => format!(
9693            "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9694            shell_quote(path),
9695            shell_quote(id),
9696            shell_quote(id)
9697        ),
9698        _ => {
9699            "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9700        }
9701    };
9702    Ok(vec![format!(
9703        "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",
9704        target.label,
9705        active_jobs.len()
9706    )])
9707}
9708
9709fn graph_db_evidence_next_commands(
9710    root: &Path,
9711    scope: Option<&str>,
9712    target: &SubstrateGraphNode,
9713    worker_context: &[SubstrateGraphNode],
9714    source_handles: &[SubstrateGraphNode],
9715    worker_results: &[SubstrateGraphNode],
9716    semantic_related: &[SubstrateGraphNode],
9717) -> Vec<String> {
9718    let mut commands = BTreeSet::new();
9719    if let Some(expand) = target.properties.get("expand") {
9720        commands.insert(expand.clone());
9721    }
9722    for worker in worker_context {
9723        if let Some(expand) = worker.properties.get("expand") {
9724            commands.insert(expand.clone());
9725        }
9726    }
9727    for source in source_handles {
9728        if let Some(expand) = source.properties.get("expand") {
9729            commands.insert(expand.clone());
9730        }
9731    }
9732    for result in worker_results {
9733        if let Some(expand) = result.properties.get("expand") {
9734            commands.insert(expand.clone());
9735        }
9736    }
9737    for semantic in semantic_related {
9738        if let Some(expand) = semantic.properties.get("expand") {
9739            commands.insert(expand.clone());
9740        }
9741    }
9742    commands.insert(format!(
9743        "tsift graph-db --path {}{} status --json",
9744        shell_quote(root.to_string_lossy().as_ref()),
9745        graph_db_scope_arg(scope)
9746    ));
9747    commands.insert(format!(
9748        "tsift graph-db --path {}{} doctor --json",
9749        shell_quote(root.to_string_lossy().as_ref()),
9750        graph_db_scope_arg(scope)
9751    ));
9752    commands.into_iter().collect()
9753}
9754
9755fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9756    vec![
9757        format!(
9758            "tsift graph-db --path {}{} refresh --json",
9759            shell_quote(root.to_string_lossy().as_ref()),
9760            graph_db_scope_arg(scope)
9761        ),
9762        format!(
9763            "tsift graph-db --path {}{} doctor --json",
9764            shell_quote(root.to_string_lossy().as_ref()),
9765            graph_db_scope_arg(scope)
9766        ),
9767    ]
9768}
9769
9770fn graph_db_evidence_replay_commands(
9771    root: &Path,
9772    scope: Option<&str>,
9773    target: &str,
9774    depth: usize,
9775    limit: usize,
9776) -> Vec<String> {
9777    vec![
9778        format!(
9779            "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9780            shell_quote(root.to_string_lossy().as_ref()),
9781            graph_db_scope_arg(scope),
9782            shell_quote(target),
9783            depth,
9784            limit
9785        ),
9786        format!(
9787            "tsift conflict-matrix --path {} {} --json",
9788            shell_quote(root.to_string_lossy().as_ref()),
9789            shell_quote(target)
9790        ),
9791    ]
9792}
9793
9794fn graph_db_evidence_packet_id(
9795    target: &str,
9796    target_node: &SubstrateGraphNode,
9797    freshness: &GraphDbFreshnessReport,
9798) -> String {
9799    stable_handle(
9800        "gevd",
9801        &format!(
9802            "{}:{}:{}:{}",
9803            GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9804            target,
9805            target_node.id,
9806            freshness.content_hash.as_deref().unwrap_or("no-hash")
9807        ),
9808    )
9809}
9810
9811pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9812    input: GraphDbEvidenceInput<'_, S>,
9813) -> Result<GraphDbEvidenceReport> {
9814    let GraphDbEvidenceInput {
9815        root,
9816        scope,
9817        backend,
9818        target,
9819        preferred_path,
9820        depth,
9821        limit,
9822        cursor,
9823        store,
9824        freshness,
9825        mut warnings,
9826    } = input;
9827    let repair_commands = graph_db_repair_commands(root, scope);
9828    if freshness.fail_closed {
9829        bail!(
9830            "graph database evidence failed closed for {} backend: {}; repair: {}",
9831            backend,
9832            freshness.diagnostics.join("; "),
9833            repair_commands.join("; ")
9834        );
9835    }
9836    let semantic_readiness =
9837        graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9838    if semantic_readiness.fail_closed {
9839        warnings.push(format!(
9840            "graph evidence semantic readiness blocked: {} — {}",
9841            semantic_readiness.reason,
9842            semantic_readiness.diagnostics.join("; ")
9843        ));
9844        warnings.push(format!(
9845            "repair: {}",
9846            semantic_readiness.next_commands.join("; then ")
9847        ));
9848    }
9849    let target_node = graph_db_resolve_evidence_target_with_path(store, target, preferred_path)?
9850        .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9851    let max_rows = if limit == 0 { usize::MAX } else { limit };
9852    let mut reachable = store.reachable_nodes_by_kinds(
9853        &target_node.id,
9854        &[
9855            "worker_context",
9856            "source_handle",
9857            "worker_result",
9858            "semantic_concept",
9859            "semantic_entity",
9860        ],
9861        depth,
9862        max_rows,
9863    )?;
9864    let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9865    let source_paths = reachable.remove("source_handle").unwrap_or_default();
9866    let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9867    let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9868    semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9869    semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9870        left_path
9871            .hops
9872            .cmp(&right_path.hops)
9873            .then(left_node.kind.cmp(&right_node.kind))
9874            .then(left_node.label.cmp(&right_node.label))
9875            .then(left_node.id.cmp(&right_node.id))
9876    });
9877    if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9878        semantic_paths.truncate(max_rows);
9879    }
9880
9881    let evidence_nodes = worker_paths
9882        .iter()
9883        .chain(source_paths.iter())
9884        .chain(worker_result_paths.iter())
9885        .chain(semantic_paths.iter())
9886        .map(|(node, _)| node.clone())
9887        .collect::<Vec<_>>();
9888    let evidence_depth_by_id = worker_paths
9889        .iter()
9890        .chain(source_paths.iter())
9891        .chain(worker_result_paths.iter())
9892        .chain(semantic_paths.iter())
9893        .map(|(node, path)| (node.id.clone(), path.hops))
9894        .collect::<BTreeMap<_, _>>();
9895    let target_query = graph_db_node_search_text(&target_node);
9896    let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9897    let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9898        std::slice::from_ref(&target_node.id),
9899        &semantic_scores,
9900        evidence_nodes,
9901        Vec::new(),
9902        Some(limit),
9903        Some(&evidence_depth_by_id),
9904        cursor,
9905    );
9906    let output_budget = budgeted.report;
9907    let truncated = budgeted.truncated;
9908    let next_cursor = budgeted.next_cursor;
9909    let retained_evidence_ids = budgeted
9910        .nodes
9911        .iter()
9912        .map(|node| node.id.as_str())
9913        .collect::<BTreeSet<_>>();
9914    let worker_context = worker_paths
9915        .iter()
9916        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9917        .map(|(node, _)| node.clone())
9918        .collect::<Vec<_>>();
9919    let source_handles = source_paths
9920        .iter()
9921        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9922        .map(|(node, _)| node.clone())
9923        .collect::<Vec<_>>();
9924    let worker_results = worker_result_paths
9925        .iter()
9926        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9927        .map(|(node, _)| node.clone())
9928        .collect::<Vec<_>>();
9929    let semantic_related = semantic_paths
9930        .iter()
9931        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9932        .map(|(node, _)| node.clone())
9933        .collect::<Vec<_>>();
9934    warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9935        store,
9936        &target_node,
9937        &worker_results,
9938    )?);
9939    if worker_context.is_empty()
9940        && source_handles.is_empty()
9941        && worker_results.is_empty()
9942        && semantic_related.is_empty()
9943    {
9944        warnings.push(format!(
9945            "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",
9946            target, target_node.kind
9947        ));
9948    }
9949    let shortest_paths = worker_paths
9950        .iter()
9951        .chain(source_paths.iter())
9952        .chain(worker_result_paths.iter())
9953        .chain(semantic_paths.iter())
9954        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9955        .map(|(node, path)| GraphDbEvidencePath {
9956            to: node.id.clone(),
9957            kind: node.kind.clone(),
9958            label: node.label.clone(),
9959            path: Some(path.clone()),
9960            expand: node.properties.get("expand").cloned(),
9961        })
9962        .collect::<Vec<_>>();
9963    let next_commands = graph_db_evidence_next_commands(
9964        root,
9965        scope,
9966        &target_node,
9967        &worker_context,
9968        &source_handles,
9969        &worker_results,
9970        &semantic_related,
9971    );
9972    let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9973    let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9974    let projection_hash = freshness.content_hash.clone();
9975
9976    Ok(GraphDbEvidenceReport {
9977        root: root.to_string_lossy().to_string(),
9978        scope: scope.map(str::to_string),
9979        backend: backend.to_string(),
9980        contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9981        target: target.to_string(),
9982        packet_id,
9983        projection_hash,
9984        freshness,
9985        target_node: target_node.into(),
9986        worker_context: worker_context.into_iter().map(Into::into).collect(),
9987        source_handles: source_handles.into_iter().map(Into::into).collect(),
9988        worker_results: worker_results.into_iter().map(Into::into).collect(),
9989        semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9990        shortest_paths,
9991        output_budget: Some(output_budget),
9992        truncated,
9993        next_cursor,
9994        next_commands,
9995        replay_commands,
9996        repair_commands,
9997        fixture_coverage: GraphDbFixtureCoverage {
9998            test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9999                .to_string(),
10000            fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
10001            assertions: vec![
10002                "backlog id and job packet handle resolve to graph nodes".to_string(),
10003                "worker_context rows are reachable from queued work".to_string(),
10004                "source_handle rows are reachable through bounded shortest paths".to_string(),
10005                "worker_result rows are reachable from completed or blocked work".to_string(),
10006            ],
10007        },
10008        warnings,
10009    })
10010}
10011
10012fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
10013    println!(
10014        "graph-db evidence backend: {} target: {} [{}] packet:{}",
10015        report.backend, report.target_node.id, report.target_node.kind, report.packet_id
10016    );
10017    let page_info = if report.truncated {
10018        let cursor = report.next_cursor.as_deref().unwrap_or("?");
10019        format!(" (truncated, next_cursor: {cursor})")
10020    } else {
10021        String::new()
10022    };
10023    println!(
10024        "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
10025        report.worker_context.len(),
10026        report.source_handles.len(),
10027        report.worker_results.len(),
10028        report.semantic_related.len(),
10029        report.shortest_paths.len()
10030    );
10031    for path in &report.shortest_paths {
10032        if let Some(graph_path) = &path.path {
10033            println!(
10034                "path: {} hop(s) {}",
10035                graph_path.hops,
10036                graph_path.nodes.join(" -> ")
10037            );
10038        }
10039    }
10040    for command in &report.next_commands {
10041        println!("next: {command}");
10042    }
10043    for warning in &report.warnings {
10044        println!("warning: {warning}");
10045    }
10046}
10047
10048pub(crate) fn print_graph_db_evidence_report(
10049    report: &GraphDbEvidenceReport,
10050    format: OutputFormat,
10051) -> Result<()> {
10052    if format.json_output {
10053        let page_info = if report.truncated {
10054            let cursor = report.next_cursor.as_deref().unwrap_or("?");
10055            format!(" (truncated, next_cursor: {cursor})")
10056        } else {
10057            String::new()
10058        };
10059        print_json_or_envelope(
10060            report,
10061            &format,
10062            "graph-db",
10063            "evidence",
10064            ToolEnvelopeSummary {
10065                text: format!(
10066                    "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}",
10067                    report.target,
10068                    report.worker_context.len(),
10069                    report.source_handles.len(),
10070                    report.worker_results.len(),
10071                    report.semantic_related.len(),
10072                    report.shortest_paths.len()
10073                ),
10074                metrics: vec![
10075                    envelope_metric("backend", &report.backend),
10076                    envelope_metric("worker_context", report.worker_context.len()),
10077                    envelope_metric("source_handles", report.source_handles.len()),
10078                    envelope_metric("worker_results", report.worker_results.len()),
10079                    envelope_metric("semantic_related", report.semantic_related.len()),
10080                    envelope_metric("paths", report.shortest_paths.len()),
10081                ],
10082            },
10083            report.truncated,
10084            report.next_commands.clone(),
10085        )
10086    } else {
10087        print_graph_db_evidence_human(report);
10088        Ok(())
10089    }
10090}
10091
10092pub(crate) fn graph_db_report_from_store(
10093    root: &Path,
10094    scope: Option<&str>,
10095    backend: &str,
10096    query: GraphDbQuery,
10097    store: &impl GraphStore,
10098    freshness: GraphDbFreshnessReport,
10099    warnings: Vec<String>,
10100) -> Result<GraphDbReport> {
10101    if freshness.fail_closed {
10102        bail!(
10103            "graph database read failed closed for {} backend: {}",
10104            backend,
10105            freshness.diagnostics.join("; ")
10106        );
10107    }
10108    let mut report = GraphDbReport {
10109        root: root.to_string_lossy().to_string(),
10110        scope: scope.map(str::to_string),
10111        backend: backend.to_string(),
10112        query: format!("{query:?}"),
10113        freshness,
10114        readiness: None,
10115        schema: None,
10116        node: None,
10117        edge: None,
10118        nodes: Vec::new(),
10119        edges: Vec::new(),
10120        ranked_neighbors: Vec::new(),
10121        semantic_related: Vec::new(),
10122        neighborhood_ranking_gate: None,
10123        ranked_neighborhood_comparison: None,
10124        knowledge_retrieval: None,
10125        output_budget: None,
10126        path: None,
10127        page: None,
10128        warnings,
10129    };
10130
10131    match query {
10132        GraphDbQuery::Refresh => {
10133            bail!("graph-db refresh must be handled by the refresh command path");
10134        }
10135        GraphDbQuery::Status => {
10136            bail!("graph-db status must be handled by the status command path");
10137        }
10138        GraphDbQuery::Doctor => {
10139            bail!("graph-db doctor must be handled by the doctor command path");
10140        }
10141        GraphDbQuery::Drift => {
10142            bail!("graph-db drift must be handled by the drift command path");
10143        }
10144        GraphDbQuery::Compact { .. } => {
10145            bail!("graph-db compact must be handled by the compact command path");
10146        }
10147        GraphDbQuery::SnapshotExport { .. } => {
10148            bail!("graph-db snapshot-export must be handled by the snapshot command path");
10149        }
10150        GraphDbQuery::SnapshotImport { .. } => {
10151            bail!("graph-db snapshot-import must be handled by the snapshot command path");
10152        }
10153        GraphDbQuery::BackendEval { .. } => {
10154            bail!("graph-db backend-eval must be handled by the benchmark command path");
10155        }
10156        GraphDbQuery::Evidence { .. } => {
10157            bail!("graph-db evidence must be handled by the evidence command path");
10158        }
10159        GraphDbQuery::Related {
10160            query,
10161            kind,
10162            depth,
10163            seed_limit,
10164            limit,
10165        } => {
10166            let semantic =
10167                semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
10168            let SemanticRelatedReport {
10169                items,
10170                warnings: semantic_warnings,
10171                ..
10172            } = semantic;
10173            let readiness = graph_db_semantic_readiness(
10174                root,
10175                scope,
10176                (!items.is_empty()).then_some(items.len()),
10177            );
10178            report.warnings.extend(semantic_warnings);
10179            let seed_ids = items
10180                .iter()
10181                .map(|item| item.handle.clone())
10182                .collect::<Vec<_>>();
10183            let semantic_scores = items
10184                .iter()
10185                .map(|item| (item.handle.clone(), item.score))
10186                .collect::<BTreeMap<_, _>>();
10187            let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
10188            let seed_count = seed_ids.len();
10189            let mut diagnostics = subgraph.diagnostics;
10190            let budgeted = graph_db_apply_output_budget(
10191                &seed_ids,
10192                &semantic_scores,
10193                subgraph.nodes,
10194                subgraph.edges,
10195                Some(limit),
10196            );
10197            let budget_report = budgeted.report;
10198            let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
10199            diagnostics.extend(budget_report.diagnostics.clone());
10200            diagnostics.extend(readiness.diagnostics.clone());
10201
10202            report.readiness = Some(readiness);
10203            report.semantic_related = items;
10204            if let Some(seed_id) = seed_ids.first() {
10205                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
10206                report.ranked_neighbors = graph_db_ranked_neighbors(
10207                    seed_id,
10208                    &budgeted.nodes,
10209                    &budgeted.edges,
10210                    ranked_neighbor_cap,
10211                );
10212                report.neighborhood_ranking_gate =
10213                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10214            }
10215            report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10216            report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10217            report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
10218                mode: "semantic_seeded_neighborhood".to_string(),
10219                query,
10220                seed_kind: semantic_related_kind_name(kind).to_string(),
10221                seed_limit,
10222                seed_count,
10223                depth,
10224                limit,
10225                node_count: report.nodes.len(),
10226                edge_count: report.edges.len(),
10227                truncated: subgraph.truncated || dropped_by_budget,
10228                traversal: "incident_plus_outgoing_edges".to_string(),
10229                freshness_boundary:
10230                    "semantic rows must come from refreshed summary or tsift-memory graph records"
10231                        .to_string(),
10232                privacy_boundary:
10233                    "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
10234                        .to_string(),
10235                diagnostics,
10236            });
10237            report.output_budget = Some(budget_report);
10238        }
10239        GraphDbQuery::Schema => {
10240            report.schema = Some(graph_db_schema());
10241        }
10242        GraphDbQuery::Node { id } => {
10243            report.node = store.node(&id)?.map(Into::into);
10244        }
10245        GraphDbQuery::Edge { id } => {
10246            report.edge = store.edge(&id)?.map(Into::into);
10247        }
10248        GraphDbQuery::Edges {
10249            edge_kind,
10250            cursor,
10251            limit,
10252            property_filters,
10253        } => {
10254            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10255            let paged = store.paged_edges(
10256                edge_kind.as_deref(),
10257                graph_db_query_options_for_store(&options),
10258            )?;
10259            report.edges = paged.edges.into_iter().map(Into::into).collect();
10260            report.page = Some(graph_db_page_report_from_store(
10261                paged.page,
10262                options.property_filters,
10263            ));
10264        }
10265        GraphDbQuery::Incident {
10266            id,
10267            edge_kind,
10268            cursor,
10269            limit,
10270            property_filters,
10271        } => {
10272            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10273            let paged = store.paged_incident_edges(
10274                &id,
10275                edge_kind.as_deref(),
10276                graph_db_query_options_for_store(&options),
10277            )?;
10278            report.edges = paged.edges.into_iter().map(Into::into).collect();
10279            report.page = Some(graph_db_page_report_from_store(
10280                paged.page,
10281                options.property_filters,
10282            ));
10283        }
10284        GraphDbQuery::Kind {
10285            kind,
10286            cursor,
10287            limit,
10288            property_filters,
10289        } => {
10290            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10291            let paged =
10292                store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
10293            report.nodes = paged.nodes.into_iter().map(Into::into).collect();
10294            report.edges = paged.edges.into_iter().map(Into::into).collect();
10295            report.page = Some(graph_db_page_report_from_store(
10296                paged.page,
10297                options.property_filters,
10298            ));
10299        }
10300        GraphDbQuery::Neighborhood {
10301            id,
10302            depth,
10303            edge_kind,
10304            cursor,
10305            limit,
10306            property_filters,
10307        } => {
10308            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10309            if let Some(paged) = store.paged_neighborhood(
10310                &id,
10311                depth,
10312                edge_kind.as_deref(),
10313                graph_db_query_options_for_store(&options),
10314            )? {
10315                let budgeted = graph_db_apply_output_budget(
10316                    std::slice::from_ref(&id),
10317                    &BTreeMap::new(),
10318                    paged.nodes,
10319                    paged.edges,
10320                    options.limit,
10321                );
10322                let budget_report = budgeted.report;
10323                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
10324                let ranked_neighbors = graph_db_ranked_neighbors(
10325                    &id,
10326                    &budgeted.nodes,
10327                    &budgeted.edges,
10328                    ranked_neighbor_cap,
10329                );
10330                let comparison = graph_db_ranked_neighborhood_comparison(
10331                    &id,
10332                    depth,
10333                    edge_kind.as_deref(),
10334                    options.limit,
10335                    &budgeted.nodes,
10336                    &budgeted.edges,
10337                    store,
10338                )?;
10339                report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10340                report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10341                report.ranked_neighbors = ranked_neighbors;
10342                report.neighborhood_ranking_gate =
10343                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10344                let mut page =
10345                    graph_db_page_report_from_store(paged.page, options.property_filters);
10346                page.returned_nodes = report.nodes.len();
10347                page.returned_edges = report.edges.len();
10348                page.truncated |= !budget_report.dropped_by_budget.is_empty();
10349                page.diagnostics.extend(budget_report.diagnostics.clone());
10350                report.page = Some(page);
10351                report.output_budget = Some(budget_report);
10352                if let Some(comparison) = comparison {
10353                    report.ranked_neighborhood_comparison = Some(comparison);
10354                }
10355            }
10356        }
10357        GraphDbQuery::Path {
10358            from,
10359            to,
10360            edge_kind,
10361            max_hops,
10362        } => {
10363            report.path =
10364                store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
10365            if let Some(max_hops) = max_hops
10366                && report.path.is_none()
10367            {
10368                report.warnings.push(format!(
10369                    "no directed path found within --max-hops {}",
10370                    max_hops
10371                ));
10372            }
10373        }
10374        GraphDbQuery::Map { .. } => {
10375            bail!("graph-db map must be handled by the map command path");
10376        }
10377    }
10378    Ok(report)
10379}
10380
10381pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
10382    if compact {
10383        println!(
10384            "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
10385            report.backend,
10386            report.query,
10387            report.nodes.len() + usize::from(report.node.is_some()),
10388            report.edges.len() + usize::from(report.edge.is_some()),
10389            report.freshness.status
10390        );
10391        return;
10392    }
10393    println!("graph-db backend: {}", report.backend);
10394    println!("freshness: {}", report.freshness.status);
10395    if let Some(readiness) = &report.readiness {
10396        println!(
10397            "readiness: {} reason: {} fail_closed: {}",
10398            readiness.status, readiness.reason, readiness.fail_closed
10399        );
10400        for diagnostic in &readiness.diagnostics {
10401            println!("readiness diagnostic: {diagnostic}");
10402        }
10403        for command in &readiness.next_commands {
10404            println!("readiness next: {command}");
10405        }
10406    }
10407    if let Some(schema) = &report.schema {
10408        println!(
10409            "schema: {} node fields, {} edge fields, {} operations",
10410            schema.node_fields.len(),
10411            schema.edge_fields.len(),
10412            schema.operations.len()
10413        );
10414    }
10415    if let Some(node) = &report.node {
10416        println!("node: {} [{}] {}", node.id, node.kind, node.label);
10417    }
10418    if let Some(edge) = &report.edge {
10419        let edge_full: SubstrateGraphEdge = edge.into();
10420        println!(
10421            "edge: {} {} -{}-> {}",
10422            graph_db_edge_key(&edge_full),
10423            edge.from_id,
10424            edge.kind,
10425            edge.to_id
10426        );
10427    }
10428    if let Some(knowledge) = &report.knowledge_retrieval {
10429        println!(
10430            "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
10431            knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
10432        );
10433    }
10434    for item in &report.semantic_related {
10435        println!(
10436            "semantic_seed: {:.3} [{}] {} ({})",
10437            item.score, item.kind, item.label, item.handle
10438        );
10439    }
10440    for node in &report.nodes {
10441        println!("node: {} [{}] {}", node.id, node.kind, node.label);
10442    }
10443    for edge in &report.edges {
10444        let edge_full: SubstrateGraphEdge = edge.into();
10445        println!(
10446            "edge: {} {} -{}-> {}",
10447            graph_db_edge_key(&edge_full),
10448            edge.from_id,
10449            edge.kind,
10450            edge.to_id
10451        );
10452    }
10453    for neighbor in &report.ranked_neighbors {
10454        println!(
10455            "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
10456            neighbor.rank,
10457            neighbor.score,
10458            neighbor
10459                .depth
10460                .map(|depth| depth.to_string())
10461                .unwrap_or_else(|| "unknown".to_string()),
10462            neighbor.node_id,
10463            neighbor.kind,
10464            neighbor.label
10465        );
10466    }
10467    if let Some(gate) = &report.neighborhood_ranking_gate {
10468        println!(
10469            "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
10470            gate.status, gate.default_order, gate.ranked_output_default
10471        );
10472    }
10473    if let Some(path) = &report.path {
10474        println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
10475    }
10476    if let Some(page) = &report.page {
10477        if let Some(next_cursor) = &page.next_cursor {
10478            println!("next_cursor: {next_cursor}");
10479        }
10480        for diagnostic in &page.diagnostics {
10481            println!("page: {diagnostic}");
10482        }
10483    }
10484    for warning in &report.warnings {
10485        println!("warning: {warning}");
10486    }
10487}
10488
10489pub(crate) fn graph_db_backend_eval_phase_timing(
10490    name: &str,
10491    duration_micros: u128,
10492    detail: &str,
10493) -> GraphDbBackendEvalPhaseTiming {
10494    GraphDbBackendEvalPhaseTiming {
10495        name: name.to_string(),
10496        duration_micros,
10497        detail: detail.to_string(),
10498    }
10499}
10500
10501pub(crate) fn graph_db_backend_eval_timed_phase<T>(
10502    phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
10503    name: &str,
10504    detail: &str,
10505    run: impl FnOnce() -> Result<T>,
10506) -> Result<T> {
10507    let started = Instant::now();
10508    let result = run();
10509    phases.push(graph_db_backend_eval_phase_timing(
10510        name,
10511        started.elapsed().as_micros(),
10512        detail,
10513    ));
10514    result
10515}
10516
10517pub(crate) fn graph_db_backend_eval_refresh_total_micros(
10518    phases: &[GraphDbBackendEvalPhaseTiming],
10519) -> u128 {
10520    phases
10521        .iter()
10522        .filter(|phase| phase.name != "conflict_matrix_preparation")
10523        .map(|phase| phase.duration_micros)
10524        .sum()
10525}
10526
10527pub(crate) fn graph_db_backend_eval_cached_refresh(
10528    root: &Path,
10529    scope: Option<&str>,
10530    source_watermark: Option<&str>,
10531) -> Result<
10532    Option<(
10533        TraversalGraphBuild,
10534        SqliteProjectionRefresh,
10535        Vec<GraphDbBackendEvalPhaseTiming>,
10536    )>,
10537> {
10538    let Some(source_watermark) = source_watermark else {
10539        return Ok(None);
10540    };
10541    let graph_db = graph_substrate_db_path(root, scope);
10542    if !graph_db.exists() {
10543        return Ok(None);
10544    }
10545
10546    let started = Instant::now();
10547    let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
10548        Ok(store) => store,
10549        Err(_) => return Ok(None),
10550    };
10551    if store.has_user_triggers().unwrap_or(true) {
10552        return Ok(None);
10553    }
10554    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
10555    if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
10556        return Ok(None);
10557    }
10558
10559    let phases = vec![
10560        graph_db_backend_eval_phase_timing(
10561            "source_graph_build",
10562            started.elapsed().as_micros(),
10563            "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10564        ),
10565        graph_db_backend_eval_phase_timing(
10566            "projection_rows",
10567            0,
10568            "reused cached provider-neutral projection rows from graph.db",
10569        ),
10570        graph_db_backend_eval_phase_timing(
10571            "sqlite_open",
10572            0,
10573            "reused existing graph.db projection without opening a write transaction",
10574        ),
10575    ];
10576    let refresh = SqliteProjectionRefresh {
10577        scope: scope.unwrap_or("root").to_string(),
10578        projection_version: freshness
10579            .projection_version
10580            .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
10581        source_watermark: Some(source_watermark.to_string()),
10582        tombstoned_nodes: Vec::new(),
10583        tombstoned_edges: Vec::new(),
10584        upserted_nodes: 0,
10585        upserted_edges: 0,
10586        unchanged_nodes: 0,
10587        unchanged_edges: 0,
10588        upserted_properties: 0,
10589        unchanged_properties: 0,
10590        deleted_properties: 0,
10591        deleted_nodes: 0,
10592        deleted_edges: 0,
10593        pruned_tombstones: 0,
10594        file_size_bytes_before: None,
10595        file_size_bytes_after: None,
10596        phase_timings: Vec::new(),
10597    };
10598    Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10599}
10600
10601pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10602    phases: &[GraphDbBackendEvalPhaseTiming],
10603) -> bool {
10604    phases.iter().any(|phase| {
10605        phase.name == "source_graph_build"
10606            && phase.detail.contains("reused current graph.db projection")
10607    })
10608}
10609
10610pub(crate) fn graph_db_backend_eval_update_source_watermark(
10611    root: &Path,
10612    path_hint: &Path,
10613    scope: Option<&str>,
10614) -> Result<()> {
10615    let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10616        return Ok(());
10617    };
10618    let graph_db = graph_substrate_db_path(root, scope);
10619    let mut store = SqliteGraphStore::open(&graph_db)?;
10620    store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10621    Ok(())
10622}
10623
10624pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10625    root: &Path,
10626    path_hint: &Path,
10627    scope: Option<&str>,
10628) -> Result<(
10629    TraversalGraphBuild,
10630    SqliteProjectionRefresh,
10631    Vec<GraphDbBackendEvalPhaseTiming>,
10632)> {
10633    let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10634    if let Some(cached) =
10635        graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10636    {
10637        return Ok(cached);
10638    }
10639
10640    let mut phases = Vec::new();
10641    let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10642        "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"
10643    } else {
10644        "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10645    };
10646    let source_graph = graph_db_backend_eval_timed_phase(
10647        &mut phases,
10648        "source_graph_build",
10649        source_graph_detail,
10650        || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10651    )?;
10652    let projection = graph_db_backend_eval_timed_phase(
10653        &mut phases,
10654        "projection_rows",
10655        "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10656        || traversal_projection_from_graph(root, scope, &source_graph),
10657    )?;
10658    let graph_db = graph_substrate_db_path(root, scope);
10659    let mut store = graph_db_backend_eval_timed_phase(
10660        &mut phases,
10661        "sqlite_open",
10662        "open the local SQLite graph.db with WAL and busy-timeout settings",
10663        || SqliteGraphStore::open(&graph_db),
10664    )?;
10665    let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10666        .ok()
10667        .flatten();
10668    let refresh = store.replace_projection_with_version(
10669        scope.unwrap_or("root"),
10670        &projection,
10671        Some(GRAPH_PROJECTION_VERSION),
10672        refreshed_source_watermark
10673            .or(source_watermark)
10674            .or_else(|| graph_projection_content_hash(&projection)),
10675    )?;
10676    phases.extend(
10677        refresh
10678            .phase_timings
10679            .iter()
10680            .map(|phase| GraphDbBackendEvalPhaseTiming {
10681                name: phase.name.clone(),
10682                duration_micros: phase.duration_micros,
10683                detail: phase.detail.clone(),
10684            }),
10685    );
10686    Ok((source_graph, refresh, phases))
10687}
10688
10689fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10690    root.join(".tsift/backend-eval-cache")
10691}
10692
10693fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10694    graph_db_backend_eval_disk_cache_dir(root)
10695        .join(kind)
10696        .join(format!("{key}.json.gz"))
10697}
10698
10699fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10700    graph_db_backend_eval_disk_cache_dir(root)
10701        .join(kind)
10702        .join(format!("{key}.json"))
10703}
10704
10705#[derive(Default, Clone)]
10706struct GraphDbBackendEvalDiskCacheReadProfile {
10707    file_read_micros: u128,
10708    gzip_decode_micros: u128,
10709    serde_decode_micros: u128,
10710    legacy: bool,
10711}
10712
10713fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10714    root: &Path,
10715    kind: &str,
10716    key: &str,
10717) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10718    let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10719    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10720    let read_started = Instant::now();
10721    let read_result = fs::read(&path);
10722    profile.file_read_micros = read_started.elapsed().as_micros();
10723    if let Ok(bytes) = read_result {
10724        let decode_started = Instant::now();
10725        let mut decoder = GzDecoder::new(bytes.as_slice());
10726        let mut decoded = Vec::new();
10727        let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10728        profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10729        if decode_ok {
10730            let serde_started = Instant::now();
10731            let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10732            profile.serde_decode_micros = serde_started.elapsed().as_micros();
10733            if let Some(value) = parsed {
10734                return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10735            }
10736        }
10737    }
10738
10739    let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10740    let legacy_started = Instant::now();
10741    let bytes = fs::read(legacy_path).ok()?;
10742    profile.file_read_micros = profile
10743        .file_read_micros
10744        .saturating_add(legacy_started.elapsed().as_micros());
10745    let serde_started = Instant::now();
10746    let value = serde_json::from_slice(&bytes).ok()?;
10747    profile.serde_decode_micros = profile
10748        .serde_decode_micros
10749        .saturating_add(serde_started.elapsed().as_micros());
10750    profile.legacy = true;
10751    Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10752}
10753
10754#[derive(Default, Clone)]
10755struct GraphDbBackendEvalDiskCacheWriteProfile {
10756    serde_encode_micros: u128,
10757    gzip_encode_micros: u128,
10758    file_write_micros: u128,
10759}
10760
10761fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10762    root: &Path,
10763    kind: &str,
10764    key: &str,
10765    value: &T,
10766) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10767    let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10768    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10769    let parent = path.parent()?;
10770    if fs::create_dir_all(parent).is_err() {
10771        return None;
10772    }
10773    let serde_started = Instant::now();
10774    let bytes = serde_json::to_vec(value).ok()?;
10775    profile.serde_encode_micros = serde_started.elapsed().as_micros();
10776    let gzip_started = Instant::now();
10777    let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10778    if encoder.write_all(&bytes).is_err() {
10779        return None;
10780    }
10781    let encoded = encoder.finish().ok()?;
10782    profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10783    let write_started = Instant::now();
10784    if fs::write(&path, &encoded).is_err() {
10785        return None;
10786    }
10787    profile.file_write_micros = write_started.elapsed().as_micros();
10788    Some((encoded.len() as u64, bytes.len() as u64, profile))
10789}
10790
10791fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10792    let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10793    let Ok(entries) = fs::read_dir(dir) else {
10794        return (0, 0);
10795    };
10796    let keep_name = format!("{keep_key}.json.gz");
10797    let mut pruned_files = 0usize;
10798    let mut pruned_bytes = 0u64;
10799    for entry in entries.flatten() {
10800        let path = entry.path();
10801        if !path.is_file() {
10802            continue;
10803        }
10804        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10805            continue;
10806        };
10807        if name == keep_name {
10808            continue;
10809        }
10810        let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10811        if !is_backend_eval_cache {
10812            continue;
10813        }
10814        let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10815        if fs::remove_file(&path).is_ok() {
10816            pruned_files += 1;
10817            pruned_bytes += bytes;
10818        }
10819    }
10820    (pruned_files, pruned_bytes)
10821}
10822
10823fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10824    root: &Path,
10825    source_root: &Path,
10826) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10827    let mut rows = Vec::new();
10828    let mut entries = walk::walk_files(source_root)?;
10829    entries.sort_by(|left, right| left.path.cmp(&right.path));
10830    for entry in entries {
10831        if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10832            continue;
10833        }
10834        if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10835            continue;
10836        }
10837        let bytes = fs::read(&entry.path)
10838            .with_context(|| format!("reading source input {}", entry.path.display()))?;
10839        rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10840            path: traversal_watermark_path(root, &entry.path),
10841            bytes: bytes.len() as u64,
10842            content_hash: content_hash(&bytes)?,
10843        });
10844    }
10845    Ok(rows)
10846}
10847
10848fn graph_db_backend_eval_full_projection_source_watermark(
10849    root: &Path,
10850    scope: Option<&str>,
10851) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10852    let path_hint = root;
10853    let mut detail_parts = Vec::new();
10854    let mut parts = vec![
10855        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10856        format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10857        "watermark_kind:stable_full_projection_inputs".to_string(),
10858        format!("scope:{}", scope.unwrap_or("root")),
10859        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10860    ];
10861
10862    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10863    match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10864        Some(db_path) => {
10865            let db = index::IndexDb::open_read_only_resilient(db_path)?;
10866            parts.push("index_mode:indexed".to_string());
10867            detail_parts.push("mode=indexed".to_string());
10868            parts.push(format!(
10869                "index_source_root:{}",
10870                traversal_watermark_path(root, &gate.source_root)
10871            ));
10872
10873            let symbols = db
10874                .all_symbols()?
10875                .into_iter()
10876                .filter(|symbol| {
10877                    !traversal_path_is_generated_artifact(
10878                        root,
10879                        &gate.source_root,
10880                        Path::new(&symbol.file),
10881                    ) && !traversal_path_is_session_markdown(
10882                        root,
10883                        &gate.source_root,
10884                        Path::new(&symbol.file),
10885                    )
10886                })
10887                .collect::<Vec<_>>();
10888            let symbols_hash = content_hash(&symbols)?;
10889            detail_parts.push(format!("symbols={symbols_hash}"));
10890            parts.push(format!("index_symbols:{symbols_hash}"));
10891
10892            let edges = db
10893                .all_stored_edges()?
10894                .into_iter()
10895                .filter(|edge| {
10896                    !traversal_path_is_generated_artifact(
10897                        root,
10898                        &gate.source_root,
10899                        Path::new(&edge.caller_file),
10900                    ) && !traversal_path_is_session_markdown(
10901                        root,
10902                        &gate.source_root,
10903                        Path::new(&edge.caller_file),
10904                    )
10905                })
10906                .collect::<Vec<_>>();
10907            let edges_hash = content_hash(&edges)?;
10908            detail_parts.push(format!("call_edges={edges_hash}"));
10909            parts.push(format!("index_call_edges:{edges_hash}"));
10910
10911            let routes = db
10912                .all_routes()?
10913                .into_iter()
10914                .filter(|route| {
10915                    !traversal_path_is_generated_artifact(
10916                        root,
10917                        &gate.source_root,
10918                        Path::new(&route.file),
10919                    ) && !traversal_path_is_session_markdown(
10920                        root,
10921                        &gate.source_root,
10922                        Path::new(&route.file),
10923                    )
10924                })
10925                .collect::<Vec<_>>();
10926            let routes_hash = content_hash(&routes)?;
10927            detail_parts.push(format!("routes={routes_hash}"));
10928            parts.push(format!("index_routes:{routes_hash}"));
10929        }
10930        None => {
10931            parts.push("index_mode:raw_fallback".to_string());
10932            detail_parts.push("mode=raw_fallback".to_string());
10933            parts.push(format!(
10934                "raw_source_root:{}",
10935                traversal_watermark_path(root, &gate.source_root)
10936            ));
10937            let raw_rows =
10938                graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10939            let raw_hash = content_hash(&raw_rows)?;
10940            detail_parts.push(format!("raw_source_files={raw_hash}"));
10941            parts.push(format!("raw_source_files:{raw_hash}"));
10942        }
10943    }
10944
10945    parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10946    detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10947    let summaries_start = parts.len();
10948    push_traversal_summaries_watermark_part(root, &mut parts)?;
10949    let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10950    detail_parts.push(format!("summaries={summaries_hash}"));
10951    let value = content_hash(&parts)?;
10952    detail_parts.push(format!("watermark={value}"));
10953    Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10954        value,
10955        detail: detail_parts.join(" "),
10956    })
10957}
10958
10959fn graph_db_backend_eval_full_projection_cache_key(
10960    root: &Path,
10961    scope: Option<&str>,
10962) -> Result<(String, String, String)> {
10963    let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10964    let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10965        root,
10966        scope,
10967        &source_watermark.value,
10968    )?;
10969    Ok((source_watermark.value, key, source_watermark.detail))
10970}
10971
10972fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10973    root: &Path,
10974    scope: Option<&str>,
10975    source_watermark: &str,
10976) -> Result<String> {
10977    content_hash(&serde_json::json!({
10978    "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10979    "root": root.display().to_string(),
10980    "scope": scope.unwrap_or("root"),
10981    "source_watermark": source_watermark,
10982    }))
10983}
10984
10985pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10986    root: &Path,
10987    scope: Option<&str>,
10988) -> Result<(
10989    GraphProjection,
10990    Vec<String>,
10991    Vec<GraphDbBackendEvalPhaseTiming>,
10992    GraphDbBackendEvalFullProjectionCacheStats,
10993)> {
10994    let (source_watermark, key, source_watermark_detail) =
10995        graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10996    let lookup_started = Instant::now();
10997    if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10998        graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10999            root,
11000            "full_projection",
11001            &key,
11002        )
11003        && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
11004        && cached.key == key
11005        && cached.source_watermark == source_watermark
11006    {
11007        let lookup_overhead_micros = lookup_started
11008            .elapsed()
11009            .as_micros()
11010            .saturating_sub(read_profile.file_read_micros)
11011            .saturating_sub(read_profile.gzip_decode_micros)
11012            .saturating_sub(read_profile.serde_decode_micros);
11013        let prune_started = Instant::now();
11014        let (pruned_files, pruned_bytes) =
11015            graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
11016        let prune_micros = prune_started.elapsed().as_micros();
11017        let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
11018            hit: true,
11019            disk_bytes,
11020            json_bytes,
11021            pruned_files,
11022            pruned_bytes,
11023        };
11024        let read_detail_suffix = if read_profile.legacy {
11025            " (legacy uncompressed cache path)"
11026        } else {
11027            ""
11028        };
11029        return Ok((
11030            cached.projection,
11031            cached.warnings,
11032            vec![
11033                graph_db_backend_eval_phase_timing(
11034                    "full_projection.cache_lookup",
11035                    lookup_overhead_micros,
11036                    &format!(
11037                        "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
11038                    ),
11039                ),
11040                graph_db_backend_eval_phase_timing(
11041                    "full_projection.cache.file_read",
11042                    read_profile.file_read_micros,
11043                    &format!(
11044                        "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
11045                    ),
11046                ),
11047                graph_db_backend_eval_phase_timing(
11048                    "full_projection.cache.gzip_decode",
11049                    read_profile.gzip_decode_micros,
11050                    "gunzip the compressed projection cache bytes",
11051                ),
11052                graph_db_backend_eval_phase_timing(
11053                    "full_projection.cache.serde_decode",
11054                    read_profile.serde_decode_micros,
11055                    "serde_json deserialize the decoded projection cache payload",
11056                ),
11057                graph_db_backend_eval_phase_timing(
11058                    "full_projection.cache.prune",
11059                    prune_micros,
11060                    "prune sibling cache files older than the current key",
11061                ),
11062                graph_db_backend_eval_phase_timing(
11063                    "full_projection.source_graph_build",
11064                    0,
11065                    "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
11066                ),
11067                graph_db_backend_eval_phase_timing(
11068                    "full_projection.projection_rows",
11069                    0,
11070                    "reused cached provider-neutral full-project projection rows",
11071                ),
11072            ],
11073            cache_stats,
11074        ));
11075    }
11076
11077    let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
11078    let mut phases = vec![graph_db_backend_eval_phase_timing(
11079        "full_projection.cache_lookup",
11080        lookup_started.elapsed().as_micros(),
11081        &format!(
11082            "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
11083        ),
11084    )];
11085    let full_source = graph_db_backend_eval_timed_phase(
11086        &mut phases,
11087        "full_projection.source_graph_build",
11088        "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
11089        || build_traversal_graph_source_with_options(root, root, scope, false),
11090    )?;
11091    let projection = graph_db_backend_eval_timed_phase(
11092        &mut phases,
11093        "full_projection.projection_rows",
11094        "provider-neutral row construction for the opt-in full-project projection dataset",
11095        || traversal_projection_from_graph(root, scope, &full_source),
11096    )?;
11097    let warnings = full_source.warnings;
11098    let refreshed_source_watermark =
11099        graph_db_backend_eval_full_projection_source_watermark(root, scope)
11100            .map(|watermark| watermark.value)
11101            .unwrap_or_else(|_| source_watermark.clone());
11102    let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
11103        root,
11104        scope,
11105        &refreshed_source_watermark,
11106    )?;
11107    let cache = GraphDbBackendEvalFullProjectionCache {
11108        version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
11109        key: write_key.clone(),
11110        source_watermark: refreshed_source_watermark,
11111        projection: projection.clone(),
11112        warnings: warnings.clone(),
11113    };
11114    if let Some((disk_bytes, json_bytes, write_profile)) =
11115        graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
11116    {
11117        cache_stats.disk_bytes = disk_bytes;
11118        cache_stats.json_bytes = json_bytes;
11119        phases.push(graph_db_backend_eval_phase_timing(
11120            "full_projection.cache.serde_encode",
11121            write_profile.serde_encode_micros,
11122            "serde_json serialize the projection cache payload before compression",
11123        ));
11124        phases.push(graph_db_backend_eval_phase_timing(
11125            "full_projection.cache.gzip_encode",
11126            write_profile.gzip_encode_micros,
11127            "gzip-compress the serialized projection cache payload",
11128        ));
11129        phases.push(graph_db_backend_eval_phase_timing(
11130            "full_projection.cache.file_write",
11131            write_profile.file_write_micros,
11132            "write the compressed projection cache bytes to .tsift/backend-eval-cache",
11133        ));
11134    }
11135    let prune_started = Instant::now();
11136    let (pruned_files, pruned_bytes) =
11137        graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
11138    phases.push(graph_db_backend_eval_phase_timing(
11139        "full_projection.cache.prune",
11140        prune_started.elapsed().as_micros(),
11141        "prune sibling cache files older than the current key",
11142    ));
11143    cache_stats.pruned_files = pruned_files;
11144    cache_stats.pruned_bytes = pruned_bytes;
11145    Ok((projection, warnings, phases, cache_stats))
11146}
11147
11148fn graph_db_backend_eval_timed(
11149    name: &str,
11150    run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
11151) -> (
11152    GraphDbBackendEvalOperation,
11153    Option<GraphDbBackendEvalSignature>,
11154) {
11155    let started = Instant::now();
11156    match run() {
11157        Ok((rows, value)) => (
11158            GraphDbBackendEvalOperation {
11159                name: name.to_string(),
11160                supported: true,
11161                status: "ok".to_string(),
11162                duration_micros: started.elapsed().as_micros(),
11163                rows,
11164                error: None,
11165            },
11166            Some(GraphDbBackendEvalSignature {
11167                operation: name.to_string(),
11168                value,
11169            }),
11170        ),
11171        Err(err) => (
11172            GraphDbBackendEvalOperation {
11173                name: name.to_string(),
11174                supported: false,
11175                status: "error".to_string(),
11176                duration_micros: started.elapsed().as_micros(),
11177                rows: None,
11178                error: Some(format!("{err:#}")),
11179            },
11180            None,
11181        ),
11182    }
11183}
11184
11185fn graph_db_backend_eval_parity(
11186    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11187    candidate_signatures: &[GraphDbBackendEvalSignature],
11188) -> GraphDbBackendEvalParity {
11189    let Some(sqlite_signatures) = sqlite_signatures else {
11190        return GraphDbBackendEvalParity {
11191            matches_sqlite: true,
11192            diagnostics: Vec::new(),
11193        };
11194    };
11195    let sqlite = sqlite_signatures
11196        .iter()
11197        .map(|signature| (signature.operation.as_str(), &signature.value))
11198        .collect::<BTreeMap<_, _>>();
11199    let candidate = candidate_signatures
11200        .iter()
11201        .map(|signature| (signature.operation.as_str(), &signature.value))
11202        .collect::<BTreeMap<_, _>>();
11203    let mut diagnostics = Vec::new();
11204    for (operation, sqlite_value) in sqlite {
11205        match candidate.get(operation) {
11206            Some(candidate_value) if *candidate_value == sqlite_value => {}
11207            Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
11208            None => diagnostics.push(format!(
11209                "{operation} did not complete for candidate backend"
11210            )),
11211        }
11212    }
11213    GraphDbBackendEvalParity {
11214        matches_sqlite: diagnostics.is_empty(),
11215        diagnostics,
11216    }
11217}
11218
11219pub(crate) fn graph_db_backend_eval_targets(
11220    store: &impl GraphStore,
11221    requested: &[String],
11222) -> Result<Vec<String>> {
11223    let requested = requested
11224        .iter()
11225        .filter_map(|target| normalize_conflict_target(target))
11226        .collect::<Vec<_>>();
11227    if !requested.is_empty() {
11228        return Ok(requested);
11229    }
11230
11231    for kind in ["backlog", "job_packet"] {
11232        let nodes = store.nodes_by_kind(kind)?;
11233        if let Some(node) = nodes.first() {
11234            if let Some(ref_id) = node.properties.get("ref_id") {
11235                return Ok(vec![ref_id.clone()]);
11236            }
11237            return Ok(vec![node.id.clone()]);
11238        }
11239    }
11240    Ok(Vec::new())
11241}
11242
11243fn graph_db_backend_eval_path_targets(
11244    store: &impl GraphStore,
11245    max_hops: usize,
11246) -> Result<Option<(String, String, usize)>> {
11247    let synthetic_from = "gsym-synthetic-0000";
11248    let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
11249    if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
11250        let outgoing = store.outgoing_edges(synthetic_from, None)?;
11251        if outgoing.len() > 1
11252            && let Some(edge) = outgoing.first()
11253        {
11254            return Ok(Some((
11255                edge.from_id.clone(),
11256                edge.to_id.clone(),
11257                GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11258            )));
11259        }
11260        return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
11261    }
11262
11263    Ok(store.sample_edge(None)?.map(|edge| {
11264        (
11265            edge.from_id,
11266            edge.to_id,
11267            GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11268        )
11269    }))
11270}
11271
11272fn graph_db_backend_eval_path_operation<S: GraphStore>(
11273    store: &S,
11274    configured_max_hops: usize,
11275) -> (
11276    GraphDbBackendEvalOperation,
11277    Option<GraphDbBackendEvalSignature>,
11278) {
11279    let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11280        "path_max_hops".to_string()
11281    } else {
11282        format!("path_max_hops_{configured_max_hops}")
11283    };
11284    graph_db_backend_eval_timed(&operation_name, || {
11285        let (from, to, effective_max_hops) =
11286            graph_db_backend_eval_path_targets(store, configured_max_hops)?
11287                .context("backend-eval path probe requires at least one traversable edge")?;
11288        let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
11289        let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11290            Some(format!(
11291                "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
11292                GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
11293            ))
11294        } else if path.is_none() && effective_max_hops == configured_max_hops {
11295            Some(format!(
11296                "path probe truncated at {configured_max_hops} hops before a route was found"
11297            ))
11298        } else {
11299            None
11300        };
11301        Ok((
11302            path.as_ref().map(|path| path.nodes.len()),
11303            serde_json::json!({
11304                "from": from,
11305                "to": to,
11306                "configured_max_hops": configured_max_hops,
11307                "effective_max_hops": effective_max_hops,
11308                "hops": path.as_ref().map(|path| path.hops),
11309                "nodes": path.as_ref().map(|path| &path.nodes),
11310                "found": path.is_some(),
11311                "warning": warning,
11312            }),
11313        ))
11314    })
11315}
11316
11317fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
11318    store: &S,
11319    depth: usize,
11320    limit: usize,
11321) -> (
11322    GraphDbBackendEvalOperation,
11323    Option<GraphDbBackendEvalSignature>,
11324) {
11325    graph_db_backend_eval_timed("neighborhood", || {
11326        let edge = match store.sample_edge(Some("calls"))? {
11327            Some(edge) => edge,
11328            None => store.sample_edge(None)?.context(
11329                "backend-eval neighborhood probe requires at least one traversable edge",
11330            )?,
11331        };
11332        let page = store
11333            .paged_neighborhood(
11334                &edge.from_id,
11335                depth,
11336                Some(&edge.kind),
11337                GraphQueryOptions {
11338                    limit: Some(limit.max(1)),
11339                    ..GraphQueryOptions::default()
11340                },
11341            )?
11342            .with_context(|| {
11343                format!(
11344                    "backend-eval neighborhood target not found: {}",
11345                    edge.from_id
11346                )
11347            })?;
11348        Ok((
11349            Some(page.nodes.len() + page.edges.len()),
11350            serde_json::json!({
11351                "center": edge.from_id,
11352                "kind": edge.kind,
11353                "depth": depth,
11354                "limit": limit.max(1),
11355                "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11356                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11357                "truncated": page.page.truncated,
11358            }),
11359        ))
11360    })
11361}
11362
11363fn graph_db_backend_eval_related_operation<S: GraphStore>(
11364    root: &Path,
11365    scope: Option<&str>,
11366    store: &S,
11367    depth: usize,
11368    limit: usize,
11369) -> (
11370    GraphDbBackendEvalOperation,
11371    Option<GraphDbBackendEvalSignature>,
11372) {
11373    graph_db_backend_eval_timed("related", || {
11374        let query = "backend evaluation";
11375        let semantic = semantic_related_report_from_store(
11376            root,
11377            scope,
11378            query,
11379            3,
11380            SemanticRelatedKind::All,
11381            store,
11382        )?;
11383        let seed_ids = semantic
11384            .items
11385            .iter()
11386            .map(|item| item.handle.clone())
11387            .collect::<Vec<_>>();
11388        let subgraph =
11389            graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
11390        Ok((
11391            Some(subgraph.nodes.len() + subgraph.edges.len()),
11392            serde_json::json!({
11393                "query": query,
11394                "seed_ids": seed_ids,
11395                "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11396                "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11397                "truncated": subgraph.truncated,
11398                "warnings": semantic.warnings,
11399                "diagnostics": subgraph.diagnostics,
11400            }),
11401        ))
11402    })
11403}
11404
11405fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
11406    serde_json::json!({
11407        "target": report.target,
11408        "target_node_id": report.target_node.id,
11409        "target_kind": report.target_node.kind,
11410        "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
11411        "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
11412        "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
11413        "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
11414        "path_count": report.shortest_paths.len(),
11415    })
11416}
11417
11418fn graph_db_backend_eval_target_resolution_signature(
11419    resolved: &[(String, SubstrateGraphNode)],
11420) -> serde_json::Value {
11421    serde_json::json!({
11422        "targets": resolved.iter().map(|(target, node)| {
11423            serde_json::json!({
11424                "target": target,
11425                "target_node_id": node.id,
11426                "target_kind": node.kind,
11427                "target_label": node.label,
11428            })
11429        }).collect::<Vec<_>>(),
11430    })
11431}
11432
11433fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
11434    serde_json::json!({
11435        "targets": report.targets,
11436        "can_parallel": report.can_parallel,
11437        "fail_closed": report.fail_closed,
11438        "cross_target_parallel_safe": report.cross_target_parallel_safe,
11439        "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
11440        "candidates": report.candidates.iter().map(|candidate| {
11441            serde_json::json!({
11442                "target": candidate.target,
11443                "risk": conflict_risk_label(candidate.risk),
11444                "owned_files": candidate.owned_files,
11445                "owned_symbols": candidate.owned_symbols,
11446                "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
11447                "previously_completed": candidate.previously_completed,
11448                "parallel_safe": candidate.parallel_safe,
11449            })
11450        }).collect::<Vec<_>>(),
11451        "conflicts": report.conflicts.iter().map(|pair| {
11452            serde_json::json!({
11453                "left": pair.left,
11454                "right": pair.right,
11455                "risk": conflict_risk_label(pair.risk),
11456            })
11457        }).collect::<Vec<_>>(),
11458    })
11459}
11460
11461fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
11462    serde_json::json!({
11463        "targets": report.targets,
11464        "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11465        "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
11466        "evidence_packet_ids": report.evidence_packet_ids,
11467        "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
11468        "truncated": report.truncated,
11469    })
11470}
11471
11472fn graph_db_backend_eval_edge_scan_probe(
11473    store: &impl GraphStore,
11474) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
11475    if let Some((edge, filter)) = store.sample_edge_with_property()? {
11476        return Ok((edge, vec![filter]));
11477    }
11478    let edge = store
11479        .sample_edge(None)?
11480        .context("backend-eval edge scan requires at least one edge")?;
11481    Ok((edge, Vec::new()))
11482}
11483
11484#[allow(clippy::too_many_arguments)]
11485fn graph_db_backend_eval_report_for_store<S: GraphStore>(
11486    backend: &str,
11487    adapter: &str,
11488    read_only: bool,
11489    root: &Path,
11490    path: &Path,
11491    scope: Option<&str>,
11492    targets: &[String],
11493    depth: usize,
11494    limit: usize,
11495    impact_limit: usize,
11496    store: &S,
11497    freshness: GraphDbFreshnessReport,
11498    refresh_operation: GraphDbBackendEvalOperation,
11499    refresh_signature: Option<GraphDbBackendEvalSignature>,
11500    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11501    extra_warnings: Vec<String>,
11502    prepared: &ConflictMatrixPreparedInputs,
11503    projection_load: &str,
11504    lock_behavior: &str,
11505    install_portability: &str,
11506) -> (
11507    GraphDbBackendEvalBackendReport,
11508    Vec<GraphDbBackendEvalSignature>,
11509) {
11510    let mut operations = vec![refresh_operation];
11511    let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
11512
11513    let (operation, signature) = graph_db_backend_eval_timed("status", || {
11514        let (nodes, edges) = store.graph_counts()?;
11515        Ok((
11516            Some(nodes + edges),
11517            serde_json::json!({
11518                "freshness": freshness.status,
11519                "nodes": nodes,
11520                "edges": edges,
11521            }),
11522        ))
11523    });
11524    operations.push(operation);
11525    signatures.extend(signature);
11526
11527    let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
11528        let edge = store
11529            .sample_edge(None)?
11530            .context("backend-eval edge lookup requires at least one edge")?;
11531        let edge_id = graph_db_edge_key(&edge);
11532        let found = store
11533            .edge(&edge_id)?
11534            .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
11535        Ok((
11536            Some(1),
11537            serde_json::json!({
11538                "edge_id": edge_id,
11539                "from_id": found.from_id,
11540                "to_id": found.to_id,
11541                "kind": found.kind,
11542            }),
11543        ))
11544    });
11545    operations.push(operation);
11546    signatures.extend(signature);
11547
11548    let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
11549        let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
11550        let page = store.paged_edges(
11551            Some(&edge.kind),
11552            GraphQueryOptions {
11553                limit: Some(limit.max(1)),
11554                property_filters: filters.clone(),
11555                ..GraphQueryOptions::default()
11556            },
11557        )?;
11558        Ok((
11559            Some(page.edges.len()),
11560            serde_json::json!({
11561                "kind": edge.kind,
11562                "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
11563                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11564                "truncated": page.page.truncated,
11565            }),
11566        ))
11567    });
11568    operations.push(operation);
11569    signatures.extend(signature);
11570
11571    let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
11572        let edge = store
11573            .sample_edge(None)?
11574            .context("backend-eval incident edge scan requires at least one edge")?;
11575        let page = store.paged_incident_edges(
11576            &edge.from_id,
11577            Some(&edge.kind),
11578            GraphQueryOptions {
11579                limit: Some(limit.max(1)),
11580                ..GraphQueryOptions::default()
11581            },
11582        )?;
11583        Ok((
11584            Some(page.edges.len()),
11585            serde_json::json!({
11586                "node_id": edge.from_id,
11587                "kind": edge.kind,
11588                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11589                "truncated": page.page.truncated,
11590            }),
11591        ))
11592    });
11593    operations.push(operation);
11594    signatures.extend(signature);
11595
11596    let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11597    operations.push(operation);
11598    signatures.extend(signature);
11599
11600    let (operation, signature) =
11601        graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11602    operations.push(operation);
11603    signatures.extend(signature);
11604
11605    for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11606        .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11607    {
11608        let (operation, signature) =
11609            graph_db_backend_eval_path_operation(store, configured_max_hops);
11610        operations.push(operation);
11611        signatures.extend(signature);
11612    }
11613
11614    let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11615        let resolved = targets
11616            .iter()
11617            .map(|target| {
11618                let node = graph_db_resolve_evidence_target(store, target)?
11619                    .with_context(|| format!("backend-eval target not found: {target}"))?;
11620                Ok((target.clone(), node))
11621            })
11622            .collect::<Result<Vec<_>>>()?;
11623        let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11624        Ok((Some(resolved.len()), signature))
11625    });
11626    operations.push(operation);
11627    signatures.extend(signature);
11628
11629    let mut evidence_for_report = None;
11630    let mut graph_snapshot_for_trace = None;
11631    let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11632        let resolved_targets =
11633            resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11634        let evidence = collect_conflict_matrix_evidence_packets(
11635            root,
11636            scope,
11637            backend,
11638            &resolved_targets,
11639            depth,
11640            limit,
11641            store,
11642            freshness.clone(),
11643        )?;
11644        let report = &evidence
11645            .first()
11646            .context("backend-eval evidence requires at least one target")?
11647            .report;
11648        let rows = evidence
11649            .iter()
11650            .map(|entry| {
11651                entry.report.worker_context.len()
11652                    + entry.report.source_handles.len()
11653                    + entry.report.worker_results.len()
11654                    + entry.report.semantic_related.len()
11655            })
11656            .sum();
11657        let signature = graph_db_backend_eval_evidence_signature(report);
11658        evidence_for_report = Some((resolved_targets, evidence));
11659        Ok((Some(rows), signature))
11660    });
11661    operations.push(operation);
11662    signatures.extend(signature);
11663
11664    let mut conflict_for_trace = None;
11665    let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11666        let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11667            let graph =
11668                conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11669            let shared_preparation =
11670                conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11671            ConflictMatrixGraphPreparedInputs {
11672                targets,
11673                graph,
11674                evidence,
11675                shared_preparation,
11676            }
11677        } else {
11678            prepare_conflict_matrix_graph_orchestration(
11679                root,
11680                scope,
11681                backend,
11682                targets,
11683                prepared,
11684                depth,
11685                limit,
11686                store,
11687                freshness.clone(),
11688            )?
11689        };
11690        let report = build_conflict_matrix_report_from_prepared_graph(
11691            root,
11692            path,
11693            scope,
11694            depth,
11695            limit,
11696            impact_limit,
11697            freshness.clone(),
11698            extra_warnings.clone(),
11699            prepared,
11700            &graph_prepared,
11701        )?;
11702        let signature = graph_db_backend_eval_conflict_signature(&report);
11703        let rows = report.candidates.len() + report.conflicts.len();
11704        conflict_for_trace = Some(report);
11705        graph_snapshot_for_trace = Some(graph_prepared.graph);
11706        Ok((Some(rows), signature))
11707    });
11708    operations.push(operation);
11709    signatures.extend(signature);
11710
11711    let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11712        let conflict = conflict_for_trace
11713            .take()
11714            .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11715        let graph = graph_snapshot_for_trace
11716            .take()
11717            .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11718        let report = build_dispatch_trace_report_from_conflict_snapshot(
11719            root,
11720            scope,
11721            conflict,
11722            graph.nodes,
11723            graph.edges,
11724            depth,
11725            limit,
11726            Vec::new(),
11727        )?;
11728        Ok((
11729            Some(report.nodes.len() + report.edges.len()),
11730            graph_db_backend_eval_dispatch_signature(&report),
11731        ))
11732    });
11733    operations.push(operation);
11734    signatures.extend(signature);
11735
11736    let total_micros = operations
11737        .iter()
11738        .map(|operation| operation.duration_micros)
11739        .sum();
11740    let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11741    (
11742        GraphDbBackendEvalBackendReport {
11743            backend: backend.to_string(),
11744            adapter: adapter.to_string(),
11745            read_only,
11746            projection_load: projection_load.to_string(),
11747            operations,
11748            total_micros,
11749            parity,
11750            lock_behavior: lock_behavior.to_string(),
11751            install_portability: install_portability.to_string(),
11752        },
11753        signatures,
11754    )
11755}
11756
11757pub(crate) fn graph_db_backend_eval_refresh_operation(
11758    duration_micros: u128,
11759    rows: usize,
11760    value: serde_json::Value,
11761) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11762    (
11763        GraphDbBackendEvalOperation {
11764            name: "refresh".to_string(),
11765            supported: true,
11766            status: "ok".to_string(),
11767            duration_micros,
11768            rows: Some(rows),
11769            error: None,
11770        },
11771        GraphDbBackendEvalSignature {
11772            operation: "refresh".to_string(),
11773            value,
11774        },
11775    )
11776}
11777
11778pub(crate) fn graph_db_backend_eval_synthetic_projection(
11779    nodes: usize,
11780    fanout: usize,
11781) -> GraphProjection {
11782    let nodes = nodes.max(12);
11783    let symbol_count = nodes.saturating_sub(9).max(1);
11784    let source = GraphProvenance::new("backend-eval", "synthetic");
11785    let mut projection_nodes = vec![
11786        SubstrateGraphNode::new(
11787            "projection:tsift-traversal:synthetic",
11788            GRAPH_PROJECTION_META_KIND,
11789            "synthetic projection",
11790        )
11791        .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11792        .with_property(
11793            "content_hash",
11794            format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11795        )
11796        .with_provenance(source.clone()),
11797        SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11798            .with_property("ref_id", "synthetic-session"),
11799        SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11800            .with_property("ref_id", "synthetic")
11801            .with_property("path", "tasks/software/synthetic.md")
11802            .with_property("line", "1")
11803            .with_property(
11804                "expand",
11805                "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11806            ),
11807        SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11808            .with_property("ref_id", "synthetic"),
11809        SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11810            .with_property("target", "synthetic")
11811            .with_property("summary", "Synthetic worker owns synthetic.rs")
11812            .with_property(
11813                "expand",
11814                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11815            ),
11816        SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11817            .with_property("file", "synthetic.rs")
11818            .with_property("start", "1")
11819            .with_property("end", "80")
11820            .with_property(
11821                "expand",
11822                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11823            ),
11824        SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11825            .with_property("path", "synthetic.rs"),
11826        SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11827            .with_property("handle", "gsem-synthetic")
11828            .with_property("label", "backend evaluation")
11829            .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11830            .with_property(
11831                "embedding",
11832                semantic_embedding_property("backend evaluation"),
11833            ),
11834        SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11835            .with_property("ref_id", "synthetic")
11836            .with_property("status", "completed")
11837            .with_property("touched_files", "synthetic.rs")
11838            .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11839    ];
11840    for idx in 0..symbol_count {
11841        projection_nodes.push(
11842            SubstrateGraphNode::new(
11843                format!("gsym-synthetic-{idx:04}"),
11844                "symbol",
11845                format!("synthetic_symbol_{idx:04}"),
11846            )
11847            .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11848            .with_property("path", "synthetic.rs")
11849            .with_property("line", (idx + 1).to_string()),
11850        );
11851    }
11852
11853    let mut projection_edges = vec![
11854        SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11855        SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11856        SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11857        SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11858        SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11859        SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11860        SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11861        SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11862        SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11863    ];
11864    for idx in 0..symbol_count {
11865        let from = format!("gsym-synthetic-{idx:04}");
11866        for offset in 1..=fanout.max(1).min(symbol_count) {
11867            let to_idx = (idx + offset) % symbol_count;
11868            if to_idx != idx {
11869                projection_edges.push(SubstrateGraphEdge::new(
11870                    from.clone(),
11871                    format!("gsym-synthetic-{to_idx:04}"),
11872                    "calls",
11873                ));
11874            }
11875        }
11876    }
11877
11878    GraphProjection {
11879        nodes: projection_nodes,
11880        edges: projection_edges
11881            .into_iter()
11882            .map(|edge| {
11883                edge.with_property("dataset", "synthetic")
11884                    .with_provenance(source.clone())
11885            })
11886            .collect(),
11887    }
11888}
11889
11890pub(crate) fn graph_db_backend_eval_promotion(
11891    datasets: &[GraphDbBackendEvalDataset],
11892    candidates: &[GraphDbExperimentalBackend],
11893) -> Vec<GraphDbBackendPromotionDecision> {
11894    let mut decisions = Vec::new();
11895    for candidate in candidates {
11896        let mut reasons = Vec::new();
11897        let mut faster_everywhere = true;
11898        let mut parity_everywhere = true;
11899        for dataset in datasets {
11900            let Some(sqlite_report) = dataset
11901                .backends
11902                .iter()
11903                .find(|backend| backend.backend == "sqlite")
11904            else {
11905                parity_everywhere = false;
11906                faster_everywhere = false;
11907                reasons.push(format!(
11908                    "{} dataset is missing SQLite baseline",
11909                    dataset.name
11910                ));
11911                continue;
11912            };
11913            let sqlite_total = sqlite_report.total_micros;
11914            let Some(candidate_report) = dataset
11915                .backends
11916                .iter()
11917                .find(|backend| backend.backend == candidate.name())
11918            else {
11919                parity_everywhere = false;
11920                reasons.push(format!("{} dataset did not run", dataset.name));
11921                continue;
11922            };
11923            if !candidate_report.parity.matches_sqlite {
11924                parity_everywhere = false;
11925                reasons.push(format!("{} parity differed from SQLite", dataset.name));
11926            }
11927            if candidate_report.total_micros >= sqlite_total {
11928                faster_everywhere = false;
11929                reasons.push(format!(
11930                    "{} total {}us did not beat SQLite {}us",
11931                    dataset.name, candidate_report.total_micros, sqlite_total
11932                ));
11933            }
11934            let sqlite_operations = sqlite_report
11935                .operations
11936                .iter()
11937                .map(|operation| (operation.name.as_str(), operation.duration_micros))
11938                .collect::<BTreeMap<_, _>>();
11939            for operation in &candidate_report.operations {
11940                if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11941                    && operation.duration_micros >= *sqlite_duration
11942                {
11943                    faster_everywhere = false;
11944                    reasons.push(format!(
11945                        "{} {} operation {}us did not beat SQLite {}us",
11946                        dataset.name, operation.name, operation.duration_micros, sqlite_duration
11947                    ));
11948                }
11949            }
11950            if candidate_report
11951                .operations
11952                .iter()
11953                .any(|operation| operation.status != "ok")
11954            {
11955                parity_everywhere = false;
11956                reasons.push(format!("{} has failed benchmark operations", dataset.name));
11957            }
11958        }
11959        let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11960            reasons.push(reason.to_string());
11961            reasons.push(
11962                "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11963                    .to_string(),
11964            );
11965            "hold"
11966        } else if parity_everywhere && faster_everywhere {
11967            reasons.push(
11968                "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11969                    .to_string(),
11970            );
11971            "eligible"
11972        } else {
11973            reasons.push(
11974                "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11975                    .to_string(),
11976            );
11977            "hold"
11978        };
11979        decisions.push(GraphDbBackendPromotionDecision {
11980            backend: candidate.name().to_string(),
11981            decision: decision.to_string(),
11982            reasons: dedupe_preserve_order(reasons),
11983            gate: candidate.promotion_gate(),
11984        });
11985    }
11986    decisions
11987}
11988
11989pub(crate) fn graph_db_backend_eval_metrics(
11990    datasets: &[GraphDbBackendEvalDataset],
11991) -> BTreeMap<String, f64> {
11992    let mut metrics = BTreeMap::new();
11993    for dataset in datasets {
11994        let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11995        metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11996        metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11997        metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11998        for backend in &dataset.backends {
11999            let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
12000            metrics.insert(
12001                format!("{prefix}.total_duration_micros"),
12002                backend.total_micros as f64,
12003            );
12004            append_graph_db_backend_eval_normalized_duration_metric(
12005                &mut metrics,
12006                &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
12007                backend.total_micros,
12008                graph_rows,
12009            );
12010            for operation in &backend.operations {
12011                metrics.insert(
12012                    format!("{prefix}.{}.duration_micros", operation.name),
12013                    operation.duration_micros as f64,
12014                );
12015                append_graph_db_backend_eval_normalized_duration_metric(
12016                    &mut metrics,
12017                    &format!(
12018                        "{prefix}.{}.duration_micros_per_1k_graph_rows",
12019                        operation.name
12020                    ),
12021                    operation.duration_micros,
12022                    graph_rows,
12023                );
12024                if let Some(rows) = operation.rows {
12025                    metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
12026                }
12027            }
12028        }
12029    }
12030    metrics
12031}
12032
12033pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
12034    dataset.nodes + dataset.edges
12035}
12036
12037pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
12038    metrics: &mut BTreeMap<String, f64>,
12039    key: &str,
12040    duration_micros: u128,
12041    graph_rows: usize,
12042) {
12043    if graph_rows == 0 {
12044        return;
12045    }
12046    metrics.insert(
12047        key.to_string(),
12048        duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
12049    );
12050}
12051
12052pub(crate) fn append_graph_db_backend_eval_phase_metrics(
12053    metrics: &mut BTreeMap<String, f64>,
12054    dataset: &str,
12055    graph_rows: usize,
12056    phases: &[GraphDbBackendEvalPhaseTiming],
12057) {
12058    for phase in phases {
12059        metrics.insert(
12060            format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
12061            phase.duration_micros as f64,
12062        );
12063        append_graph_db_backend_eval_normalized_duration_metric(
12064            metrics,
12065            &format!(
12066                "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
12067                phase.name
12068            ),
12069            phase.duration_micros,
12070            graph_rows,
12071        );
12072    }
12073}
12074
12075fn graph_db_backend_eval_base_command(
12076    root: &Path,
12077    scope: Option<&str>,
12078    full_projection: bool,
12079) -> String {
12080    let full_projection_arg = if full_projection {
12081        " --full-projection"
12082    } else {
12083        ""
12084    };
12085    format!(
12086        "tsift graph-db --path {}{} --json backend-eval{}",
12087        shell_quote(root.to_string_lossy().as_ref()),
12088        graph_db_scope_arg(scope),
12089        full_projection_arg
12090    )
12091}
12092
12093pub(crate) fn graph_db_backend_eval_metric_digest_command(
12094    root: &Path,
12095    scope: Option<&str>,
12096    full_projection: bool,
12097) -> String {
12098    format!(
12099        "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12100        graph_db_backend_eval_base_command(root, scope, full_projection)
12101    )
12102}
12103
12104fn graph_db_backend_eval_repeated_sample_command(
12105    root: &Path,
12106    scope: Option<&str>,
12107    full_projection: bool,
12108) -> String {
12109    format!(
12110        "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12111        graph_db_backend_eval_base_command(root, scope, full_projection)
12112    )
12113}
12114
12115fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
12116    let mut required_metrics = Vec::new();
12117    for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
12118        required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
12119        required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
12120        for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
12121            required_metrics.push(format!(
12122                "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
12123            ));
12124            required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
12125        }
12126    }
12127    GraphDbHopCapPromotionGate {
12128        status: "hold_64_default_until_gate_passes".to_string(),
12129        current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
12130        candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
12131        required_backend: perf_gate::BASELINE_BACKEND.to_string(),
12132        required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
12133            .iter()
12134            .map(|workload| (*workload).to_string())
12135            .collect(),
12136        required_metrics,
12137        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12138        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12139        decision_rule:
12140            "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"
12141                .to_string(),
12142    }
12143}
12144
12145fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
12146    let candidate_backends = [
12147        GraphDbExperimentalBackend::Falkordb,
12148        GraphDbExperimentalBackend::Kuzu,
12149        GraphDbExperimentalBackend::Surrealdb,
12150    ]
12151    .into_iter()
12152    .map(|backend| GraphDbBackendAdapterSpikeCandidate {
12153        backend: backend.name().to_string(),
12154        adapter_label: backend.adapter_label().to_string(),
12155        projection_load: backend.projection_load().to_string(),
12156        lock_behavior: backend.lock_behavior().to_string(),
12157        install_portability: backend.install_portability().to_string(),
12158    })
12159    .collect();
12160
12161    GraphDbBackendAdapterSpikeGate {
12162        status: "hold_real_optional_adapter_required".to_string(),
12163        candidate_backends,
12164        required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
12165            .iter()
12166            .map(|workload| (*workload).to_string())
12167            .collect(),
12168        required_checks: vec![
12169            "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
12170            "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
12171            "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
12172            "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
12173            "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
12174                .to_string(),
12175            "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
12176            "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
12177        ],
12178        decision_rule:
12179            "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"
12180                .to_string(),
12181        evidence_plan: "plans/gback-evidence.md".to_string(),
12182    }
12183}
12184
12185pub(crate) fn graph_db_backend_eval_performance_gate(
12186    root: &Path,
12187    scope: Option<&str>,
12188    full_projection: bool,
12189) -> GraphDbBackendEvalPerformanceGate {
12190    let mut required_metrics = vec![
12191        "real.sqlite.refresh.duration_micros".to_string(),
12192        "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
12193        "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
12194        "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
12195        "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12196        "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12197        "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
12198        "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
12199        "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12200        "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
12201        "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
12202        "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
12203        "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
12204        "real.sqlite.conflict_matrix.duration_micros".to_string(),
12205        "real.sqlite.dispatch_trace.duration_micros".to_string(),
12206        "real.sqlite.path_max_hops.duration_micros".to_string(),
12207        "real.sqlite.path_max_hops_128.duration_micros".to_string(),
12208        "real.sqlite.path_max_hops_256.duration_micros".to_string(),
12209        "real.sqlite.path_max_hops_512.duration_micros".to_string(),
12210        "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
12211        "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
12212        "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
12213        "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
12214        "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12215        "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12216        "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
12217            .to_string(),
12218        "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12219            .to_string(),
12220        "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12221        "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12222        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
12223        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
12224        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
12225        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
12226        "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12227            .to_string(),
12228        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
12229        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
12230            .to_string(),
12231        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
12232            .to_string(),
12233        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
12234            .to_string(),
12235    ];
12236    if full_projection {
12237        required_metrics.extend([
12238            "full_projection.cache.hit".to_string(),
12239            "full_projection.cache.disk_bytes".to_string(),
12240            "full_projection.cache.compression_ratio".to_string(),
12241            "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
12242            "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12243            "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
12244                .to_string(),
12245            "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
12246                .to_string(),
12247            "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
12248            "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
12249            "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
12250            "full_projection.sqlite.neighborhood.duration_micros".to_string(),
12251            "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
12252            "full_projection.sqlite.evidence.duration_micros".to_string(),
12253            "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
12254            "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
12255            "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
12256            "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
12257            "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
12258            "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
12259        ]);
12260    }
12261    GraphDbBackendEvalPerformanceGate {
12262        baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
12263        ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
12264            .to_string(),
12265        opt_in_real_profile:
12266            "pass --full-projection to add the full-project dataset when checking for large projection regressions"
12267                .to_string(),
12268        full_projection_cache_hit_gate: if full_projection {
12269            "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"
12270                .to_string()
12271        } else {
12272            "not evaluated until --full-projection is enabled".to_string()
12273        },
12274        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12275        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12276        normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
12277        required_metrics,
12278        digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
12279        repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
12280            root,
12281            scope,
12282            full_projection,
12283        ),
12284        hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
12285        backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
12286    }
12287}
12288
12289#[cfg(feature = "backend-surrealdb")]
12290fn graph_db_backend_eval_path_segment(value: &str) -> String {
12291    value
12292        .chars()
12293        .map(|ch| {
12294            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
12295                ch
12296            } else {
12297                '_'
12298            }
12299        })
12300        .collect()
12301}
12302
12303#[cfg(feature = "backend-surrealdb")]
12304fn graph_db_backend_eval_surrealdb_store_path(
12305    root: &Path,
12306    scope: Option<&str>,
12307    dataset: &str,
12308) -> PathBuf {
12309    root.join(".tsift/backend-eval-cache/surrealdb")
12310        .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
12311        .join(graph_db_backend_eval_path_segment(dataset))
12312        .join("surrealkv")
12313}
12314
12315pub(crate) struct GraphDbBackendEvalOptions<'a> {
12316    path: &'a Path,
12317    scope: Option<&'a str>,
12318    candidates: &'a [String],
12319    targets: &'a [String],
12320    full_projection: bool,
12321}
12322
12323#[allow(clippy::too_many_arguments)]
12324pub(crate) fn graph_db_backend_eval_dataset(
12325    name: &str,
12326    root: &Path,
12327    path: &Path,
12328    scope: Option<&str>,
12329    targets: &[String],
12330    depth: usize,
12331    limit: usize,
12332    impact_limit: usize,
12333    candidates: &[GraphDbExperimentalBackend],
12334    sqlite_store: &SqliteGraphStore,
12335    sqlite_freshness: GraphDbFreshnessReport,
12336    sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
12337    sqlite_rows: ConvexProjectionRows,
12338    extra_warnings: Vec<String>,
12339    prepared: &ConflictMatrixPreparedInputs,
12340) -> Result<GraphDbBackendEvalDataset> {
12341    let (nodes, edges) = sqlite_store.graph_counts()?;
12342    let (sqlite_operation, sqlite_signature) = sqlite_refresh;
12343    let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
12344        "sqlite",
12345        "SQLite GraphStore correctness baseline",
12346        false,
12347        root,
12348        path,
12349        scope,
12350        targets,
12351        depth,
12352        limit,
12353        impact_limit,
12354        sqlite_store,
12355        sqlite_freshness,
12356        sqlite_operation,
12357        Some(sqlite_signature),
12358        None,
12359        extra_warnings.clone(),
12360        prepared,
12361        "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
12362        "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
12363        "bundled rusqlite baseline; no external service or runtime required",
12364    );
12365
12366    let mut backends = vec![sqlite_report];
12367    for candidate in candidates {
12368        #[cfg(feature = "backend-surrealdb")]
12369        if *candidate == GraphDbExperimentalBackend::Surrealdb {
12370            let started = Instant::now();
12371            let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
12372            let (store, warm_start) =
12373                SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
12374            let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12375            let rows = candidate_nodes + candidate_edges;
12376            let mut refresh_meta = serde_json::json!({
12377                "nodes": candidate_nodes,
12378                "edges": candidate_edges,
12379            });
12380            if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
12381                refresh_meta["warm_start"] = serde_json::json!("cache_hit");
12382            }
12383            let refresh = graph_db_backend_eval_refresh_operation(
12384                started.elapsed().as_micros(),
12385                rows,
12386                refresh_meta,
12387            );
12388            let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12389            let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12390                candidate.name(),
12391                "SurrealDB SurrealKV optional adapter spike",
12392                false,
12393                root,
12394                path,
12395                scope,
12396                targets,
12397                depth,
12398                limit,
12399                impact_limit,
12400                &store,
12401                freshness,
12402                refresh.0,
12403                Some(refresh.1),
12404                Some(&sqlite_signatures),
12405                extra_warnings.clone(),
12406                prepared,
12407                "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",
12408                "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
12409                "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
12410            );
12411            backends.push(candidate_report);
12412            continue;
12413        }
12414        let started = Instant::now();
12415        let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
12416        let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12417        let rows = candidate_nodes + candidate_edges;
12418        let refresh = graph_db_backend_eval_refresh_operation(
12419            started.elapsed().as_micros(),
12420            rows,
12421            serde_json::json!({
12422                "nodes": candidate_nodes,
12423                "edges": candidate_edges,
12424            }),
12425        );
12426        let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12427        let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12428            candidate.name(),
12429            candidate.adapter_label(),
12430            true,
12431            root,
12432            path,
12433            scope,
12434            targets,
12435            depth,
12436            limit,
12437            impact_limit,
12438            &store,
12439            freshness,
12440            refresh.0,
12441            Some(refresh.1),
12442            Some(&sqlite_signatures),
12443            extra_warnings.clone(),
12444            prepared,
12445            candidate.projection_load(),
12446            candidate.lock_behavior(),
12447            candidate.install_portability(),
12448        );
12449        backends.push(candidate_report);
12450    }
12451
12452    Ok(GraphDbBackendEvalDataset {
12453        name: name.to_string(),
12454        target_count: targets.len(),
12455        nodes,
12456        edges,
12457        backends,
12458    })
12459}
12460
12461pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
12462    println!(
12463        "graph-db backend-eval baseline:{} candidates:{}",
12464        report.baseline_backend,
12465        report.candidates.join(", ")
12466    );
12467    for phase in &report.phase_timings {
12468        println!(
12469            "phase:{} {}us {}",
12470            phase.name, phase.duration_micros, phase.detail
12471        );
12472    }
12473    for dataset in &report.datasets {
12474        println!(
12475            "dataset:{} targets:{} rows:{}",
12476            dataset.name,
12477            dataset.target_count,
12478            dataset.nodes + dataset.edges
12479        );
12480        for backend in &dataset.backends {
12481            println!(
12482                "  backend:{} total:{}us parity:{}",
12483                backend.backend, backend.total_micros, backend.parity.matches_sqlite
12484            );
12485            println!("    projection-load: {}", backend.projection_load);
12486            println!("    lock-behavior: {}", backend.lock_behavior);
12487            println!("    install-portability: {}", backend.install_portability);
12488            for operation in &backend.operations {
12489                println!(
12490                    "    {} {} {}us",
12491                    operation.name, operation.status, operation.duration_micros
12492                );
12493            }
12494            for diagnostic in &backend.parity.diagnostics {
12495                println!("    parity: {diagnostic}");
12496            }
12497        }
12498    }
12499    for decision in &report.promotion {
12500        println!("promotion {}: {}", decision.backend, decision.decision);
12501        println!("  gate: {}", decision.gate.status);
12502        for reason in &decision.reasons {
12503            println!("  reason: {reason}");
12504        }
12505        for check in &decision.gate.required_checks {
12506            println!("  check: {check}");
12507        }
12508    }
12509    println!("metric-digest: {}", report.metric_digest_command);
12510    println!(
12511        "repeat-samples: {}",
12512        report.performance_gate.repeated_sample_command
12513    );
12514}
12515
12516fn traversal_expand_command(root: &Path, handle: &str) -> String {
12517    format!(
12518        "tsift traverse {} --path {} --depth 1 --limit 50",
12519        shell_quote(handle),
12520        shell_quote(root.to_string_lossy().as_ref())
12521    )
12522}
12523
12524fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
12525    let display = relativize(file, root);
12526    let handle = stable_handle("gfil", &format!("file:{display}"));
12527    TraversalNode {
12528        handle: handle.clone(),
12529        kind: "file".to_string(),
12530        label: display.clone(),
12531        ref_id: Some(display.clone()),
12532        path: Some(display),
12533        line: None,
12534        detail: None,
12535        properties: BTreeMap::new(),
12536        expand: traversal_expand_command(root, &handle),
12537    }
12538}
12539
12540fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
12541    let mut node = traversal_file_node(root, file);
12542    if let Some(path) = node.path.clone() {
12543        node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
12544        node.expand = source_read_command(root, &path, 1, 80);
12545    }
12546    node
12547}
12548
12549fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
12550    let file = relativize(&symbol.file, root);
12551    let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
12552    let handle = stable_handle("gsym", &key);
12553    TraversalNode {
12554        handle: handle.clone(),
12555        kind: "symbol".to_string(),
12556        label: symbol.name.clone(),
12557        ref_id: Some(symbol.name.clone()),
12558        path: Some(file),
12559        line: Some(symbol.line),
12560        detail: Some(format!("{} {}", symbol.language, symbol.kind)),
12561        properties: BTreeMap::new(),
12562        expand: traversal_expand_command(root, &handle),
12563    }
12564}
12565
12566fn traversal_ast_span_expand_command(
12567    root: &Path,
12568    file: &str,
12569    symbol: &index::StoredSymbol,
12570    span: &AstSpanPreview,
12571) -> String {
12572    if symbol.language == "markdown" {
12573        markdown_ast_command(root, file, Some(&span.handle))
12574    } else {
12575        let line_count = span
12576            .end_line
12577            .saturating_sub(span.start_line)
12578            .saturating_add(1)
12579            .max(1);
12580        source_read_command(root, file, span.start_line, line_count)
12581    }
12582}
12583
12584fn traversal_ast_span_node(
12585    root: &Path,
12586    symbol: &index::StoredSymbol,
12587    source: &[u8],
12588    symbols: &[&index::StoredSymbol],
12589) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12590    let span = stored_symbol_ast_span_in_file(symbol, source, symbols, usize::MAX)?;
12591    let file = relativize(&symbol.file, root);
12592    let mut properties = BTreeMap::new();
12593    properties.insert("layer".to_string(), "ast_navigation".to_string());
12594    properties.insert("language".to_string(), symbol.language.clone());
12595    properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12596    properties.insert("node_kind".to_string(), span.node_kind.clone());
12597    properties.insert("start_byte".to_string(), span.start_byte.to_string());
12598    properties.insert("end_byte".to_string(), span.end_byte.to_string());
12599    properties.insert("end_line".to_string(), span.end_line.to_string());
12600    if let Some(body_start_byte) = span.body_start_byte {
12601        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12602    }
12603    if let Some(body_end_byte) = span.body_end_byte {
12604        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12605    }
12606    if let Some(body_start_line) = span.body_start_line {
12607        properties.insert("body_start_line".to_string(), body_start_line.to_string());
12608    }
12609    if let Some(body_end_line) = span.body_end_line {
12610        properties.insert("body_end_line".to_string(), body_end_line.to_string());
12611    }
12612    if let Some(parent_handle) = &span.parent_handle {
12613        properties.insert("parent_handle".to_string(), parent_handle.clone());
12614    }
12615    if !span.child_handles.is_empty() {
12616        properties.insert("child_handles".to_string(), span.child_handles.join(","));
12617    }
12618    if let Some(parent_module) = &symbol.parent_module {
12619        properties.insert("parent_module".to_string(), parent_module.clone());
12620    }
12621    if let Some(markdown) = &span.markdown {
12622        properties.insert(
12623            "markdown_block_kind".to_string(),
12624            markdown_ast_block_kind(&symbol.kind),
12625        );
12626        if let Some(heading_level) = markdown.heading_level {
12627            properties.insert("heading_level".to_string(), heading_level.to_string());
12628        }
12629        if !markdown.section_path.is_empty() {
12630            properties.insert(
12631                "section_path".to_string(),
12632                markdown.section_path.join(" > "),
12633            );
12634        }
12635        if let Some(section_handle) = &markdown.section_handle {
12636            properties.insert("section_handle".to_string(), section_handle.clone());
12637        }
12638        if let Some(list_depth) = markdown.list_depth {
12639            properties.insert("list_depth".to_string(), list_depth.to_string());
12640        }
12641        if let Some(fence_language) = &markdown.fence_language {
12642            properties.insert("fence_language".to_string(), fence_language.clone());
12643        }
12644    }
12645
12646    let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12647    let node = TraversalNode {
12648        handle: span.handle.clone(),
12649        kind: "ast_span".to_string(),
12650        label: symbol.name.clone(),
12651        ref_id: Some(symbol.name.clone()),
12652        path: Some(file.clone()),
12653        line: Some(line),
12654        detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12655        properties,
12656        expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12657    };
12658    let entry = TraversalAstSpanIndexEntry {
12659        handle: span.handle,
12660        symbol_handle: String::new(),
12661        file_handle: None,
12662        file,
12663        name: symbol.name.clone(),
12664        kind: symbol.kind.clone(),
12665        language: symbol.language.clone(),
12666        node_kind: span.node_kind,
12667        start_byte: span.start_byte,
12668        end_byte: span.end_byte,
12669        parent_module: symbol.parent_module.clone(),
12670        markdown: span.markdown,
12671    };
12672    Some((node, entry))
12673}
12674
12675fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12676    let handle = stable_handle("gsym", &format!("symbol:{name}"));
12677    TraversalNode {
12678        handle: handle.clone(),
12679        kind: "symbol".to_string(),
12680        label: name.to_string(),
12681        ref_id: Some(name.to_string()),
12682        path: None,
12683        line: None,
12684        detail: Some("unresolved call target".to_string()),
12685        properties: BTreeMap::new(),
12686        expand: traversal_expand_command(root, &handle),
12687    }
12688}
12689
12690fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12691    let file = relativize(&route.file, root);
12692    let method = route.method.as_deref().unwrap_or("any");
12693    let key = format!(
12694        "route:{file}:{}:{}:{}",
12695        route.line, method, route.route_path
12696    );
12697    let handle = stable_handle("grte", &key);
12698    TraversalNode {
12699        handle: handle.clone(),
12700        kind: "route".to_string(),
12701        label: format!("{} {}", method.to_uppercase(), route.route_path),
12702        ref_id: Some(route.route_path.clone()),
12703        path: Some(file),
12704        line: Some(route.line),
12705        detail: Some(format!(
12706            "{} route handled by {}",
12707            route.framework, route.handler_name
12708        )),
12709        properties: BTreeMap::new(),
12710        expand: traversal_expand_command(root, &handle),
12711    }
12712}
12713
12714fn traversal_cargo_workspace_node(
12715    root: &Path,
12716    workspace: &multiplicity::CargoWorkspaceInfo,
12717) -> TraversalNode {
12718    let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12719        .to_string_lossy()
12720        .replace('\\', "/");
12721    let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12722        .to_string_lossy()
12723        .replace('\\', "/");
12724    let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12725    let mut properties = BTreeMap::new();
12726    properties.insert("layer".to_string(), "cargo_workspace".to_string());
12727    properties.insert("workspace_root".to_string(), workspace_root.clone());
12728    properties.insert("members".to_string(), workspace.members.join(","));
12729    properties.insert(
12730        "default_members".to_string(),
12731        workspace.default_members.join(","),
12732    );
12733    TraversalNode {
12734        handle: handle.clone(),
12735        kind: "cargo_workspace".to_string(),
12736        label: if workspace_root.is_empty() {
12737            "root cargo workspace".to_string()
12738        } else {
12739            workspace_root
12740        },
12741        ref_id: Some(workspace.id.clone()),
12742        path: Some(manifest),
12743        line: None,
12744        detail: Some("Cargo workspace manifest".to_string()),
12745        properties,
12746        expand: traversal_expand_command(root, &handle),
12747    }
12748}
12749
12750fn traversal_cargo_package_node(
12751    root: &Path,
12752    package: &multiplicity::CargoPackageInfo,
12753) -> TraversalNode {
12754    let manifest = relativize_pathbuf(&package.manifest_path, root)
12755        .to_string_lossy()
12756        .replace('\\', "/");
12757    let package_root = relativize_pathbuf(&package.package_root, root)
12758        .to_string_lossy()
12759        .replace('\\', "/");
12760    let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12761        .to_string_lossy()
12762        .replace('\\', "/");
12763    let handle = stable_handle(
12764        "gcpk",
12765        &format!("cargo-package:{manifest}:{}", package.name),
12766    );
12767    let mut properties = BTreeMap::new();
12768    properties.insert("layer".to_string(), "cargo_package".to_string());
12769    properties.insert("package_name".to_string(), package.name.clone());
12770    properties.insert(
12771        "normalized_name".to_string(),
12772        package.normalized_name.clone(),
12773    );
12774    properties.insert("package_root".to_string(), package_root.clone());
12775    properties.insert("workspace_root".to_string(), workspace_root);
12776    properties.insert("features".to_string(), package.features.join(","));
12777    properties.insert("targets".to_string(), package.targets.join(","));
12778    properties.insert(
12779        "dependencies".to_string(),
12780        package
12781            .dependencies
12782            .iter()
12783            .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12784            .collect::<Vec<_>>()
12785            .join(","),
12786    );
12787    TraversalNode {
12788        handle: handle.clone(),
12789        kind: "cargo_package".to_string(),
12790        label: package.name.clone(),
12791        ref_id: Some(package.scope_id.clone()),
12792        path: Some(manifest),
12793        line: None,
12794        detail: Some(format!(
12795            "Cargo package in {}",
12796            if package_root.is_empty() {
12797                "."
12798            } else {
12799                package_root.as_str()
12800            }
12801        )),
12802        properties,
12803        expand: traversal_expand_command(root, &handle),
12804    }
12805}
12806
12807fn traversal_session_node(
12808    root: &Path,
12809    markdown_path: &Path,
12810    session_id: Option<&str>,
12811) -> TraversalNode {
12812    let display = relativize_pathbuf(markdown_path, root)
12813        .to_string_lossy()
12814        .replace('\\', "/");
12815    let handle = stable_handle("gses", &format!("session:{display}"));
12816    TraversalNode {
12817        handle: handle.clone(),
12818        kind: "session".to_string(),
12819        label: session_id.unwrap_or(&display).to_string(),
12820        ref_id: session_id.map(str::to_string),
12821        path: Some(display),
12822        line: None,
12823        detail: Some("agent-doc session artifact".to_string()),
12824        properties: BTreeMap::new(),
12825        expand: traversal_expand_command(root, &handle),
12826    }
12827}
12828
12829fn traversal_backlog_node(
12830    root: &Path,
12831    markdown_path: &Path,
12832    id: &str,
12833    text: &str,
12834    line: i64,
12835) -> TraversalNode {
12836    let display = relativize_pathbuf(markdown_path, root)
12837        .to_string_lossy()
12838        .replace('\\', "/");
12839    let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12840    TraversalNode {
12841        handle: handle.clone(),
12842        kind: "backlog".to_string(),
12843        label: format!("#{id}"),
12844        ref_id: Some(id.to_string()),
12845        path: Some(display),
12846        line: Some(line),
12847        detail: Some(text.to_string()),
12848        properties: BTreeMap::new(),
12849        expand: traversal_expand_command(root, &handle),
12850    }
12851}
12852
12853fn traversal_job_packet_node(
12854    root: &Path,
12855    markdown_path: &Path,
12856    label: &str,
12857    ref_id: Option<&str>,
12858    detail: &str,
12859    line: i64,
12860) -> TraversalNode {
12861    let display = relativize_pathbuf(markdown_path, root)
12862        .to_string_lossy()
12863        .replace('\\', "/");
12864    let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12865    TraversalNode {
12866        handle: handle.clone(),
12867        kind: "job_packet".to_string(),
12868        label: label.to_string(),
12869        ref_id: ref_id.map(str::to_string),
12870        path: Some(display),
12871        line: Some(line),
12872        detail: Some(detail.to_string()),
12873        properties: BTreeMap::new(),
12874        expand: traversal_expand_command(root, &handle),
12875    }
12876}
12877
12878#[derive(Clone, Debug)]
12879struct ParsedWorkerResult {
12880    id: String,
12881    status: String,
12882    touched_files: Vec<String>,
12883    tests: Vec<String>,
12884    follow_up_ids: Vec<String>,
12885}
12886
12887fn traversal_worker_result_node(
12888    root: &Path,
12889    markdown_path: &Path,
12890    parsed: &ParsedWorkerResult,
12891    line_text: &str,
12892    line: i64,
12893) -> TraversalNode {
12894    let display = relativize_pathbuf(markdown_path, root)
12895        .to_string_lossy()
12896        .replace('\\', "/");
12897    let handle = stable_handle(
12898        "wres",
12899        &format!(
12900            "worker-result:{display}:{}:{}:{}",
12901            parsed.id, parsed.status, line
12902        ),
12903    );
12904    let mut properties = BTreeMap::new();
12905    properties.insert("status".to_string(), parsed.status.clone());
12906    if !parsed.touched_files.is_empty() {
12907        properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12908    }
12909    if !parsed.tests.is_empty() {
12910        properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12911    }
12912    if !parsed.follow_up_ids.is_empty() {
12913        properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12914    }
12915    TraversalNode {
12916        handle: handle.clone(),
12917        kind: "worker_result".to_string(),
12918        label: format!("{} #{}", parsed.status, parsed.id),
12919        ref_id: Some(parsed.id.clone()),
12920        path: Some(display),
12921        line: Some(line),
12922        detail: Some(line_text.trim().to_string()),
12923        properties,
12924        expand: traversal_expand_command(root, &handle),
12925    }
12926}
12927
12928fn traversal_tokens(input: &str) -> BTreeSet<String> {
12929    input
12930        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12931        .flat_map(|part| part.split(['_', '-']))
12932        .map(str::trim)
12933        .filter(|part| part.len() >= 3)
12934        .map(|part| part.to_ascii_lowercase())
12935        .collect()
12936}
12937
12938fn traversal_ast_span_contains(
12939    parent: &TraversalAstSpanIndexEntry,
12940    child: &TraversalAstSpanIndexEntry,
12941) -> bool {
12942    parent.handle != child.handle
12943        && parent.file == child.file
12944        && parent.start_byte <= child.start_byte
12945        && parent.end_byte >= child.end_byte
12946}
12947
12948fn traversal_ast_parent_handle<'a>(
12949    entry: &TraversalAstSpanIndexEntry,
12950    entries: &'a [TraversalAstSpanIndexEntry],
12951) -> Option<&'a str> {
12952    entries
12953        .iter()
12954        .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12955        .min_by_key(|candidate| {
12956            (
12957                candidate.end_byte.saturating_sub(candidate.start_byte),
12958                candidate.start_byte,
12959                candidate.end_byte,
12960                candidate.kind.as_str(),
12961                candidate.name.as_str(),
12962                candidate.node_kind.as_str(),
12963            )
12964        })
12965        .map(|candidate| candidate.handle.as_str())
12966}
12967
12968fn traversal_ast_enclosing_module_handle<'a>(
12969    entry: &TraversalAstSpanIndexEntry,
12970    entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12971    parent_by_handle: &BTreeMap<String, String>,
12972) -> Option<&'a str> {
12973    let mut current = parent_by_handle.get(&entry.handle);
12974    while let Some(handle) = current {
12975        let Some(parent) = entries_by_handle.get(handle) else {
12976            break;
12977        };
12978        if matches!(parent.kind.as_str(), "module" | "mod")
12979            || entry
12980                .parent_module
12981                .as_deref()
12982                .is_some_and(|module| module == parent.name)
12983        {
12984            return Some(parent.handle.as_str());
12985        }
12986        current = parent_by_handle.get(&parent.handle);
12987    }
12988    None
12989}
12990
12991fn link_ast_navigation_edges(
12992    graph: &mut TraversalGraphBuild,
12993    entries: &[TraversalAstSpanIndexEntry],
12994) {
12995    let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12996    let entries_by_handle = entries
12997        .iter()
12998        .map(|entry| (entry.handle.clone(), entry.clone()))
12999        .collect::<BTreeMap<_, _>>();
13000    let mut parent_by_handle = BTreeMap::<String, String>::new();
13001    let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
13002
13003    for entry in entries {
13004        entries_by_file
13005            .entry(entry.file.clone())
13006            .or_default()
13007            .push(entry.clone());
13008    }
13009
13010    for file_entries in entries_by_file.values() {
13011        for entry in file_entries {
13012            let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
13013            if let Some(parent) = &parent {
13014                parent_by_handle.insert(entry.handle.clone(), parent.clone());
13015            }
13016            let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
13017            children_by_parent
13018                .entry(sibling_key)
13019                .or_default()
13020                .push(entry.clone());
13021        }
13022    }
13023
13024    for entry in entries {
13025        let parent = parent_by_handle.get(&entry.handle);
13026        if let Some(parent) = parent {
13027            graph.add_edge(
13028                parent,
13029                &entry.handle,
13030                "contains",
13031                Some("AST parent contains child span".to_string()),
13032                1,
13033            );
13034            graph.add_edge(
13035                parent,
13036                &entry.handle,
13037                "child",
13038                Some("AST child span".to_string()),
13039                1,
13040            );
13041            graph.add_edge(
13042                &entry.handle,
13043                parent,
13044                "parent",
13045                Some("AST parent span".to_string()),
13046                1,
13047            );
13048        } else if let Some(file_handle) = &entry.file_handle {
13049            graph.add_edge(
13050                file_handle,
13051                &entry.handle,
13052                "contains",
13053                Some("file contains top-level AST span".to_string()),
13054                1,
13055            );
13056        }
13057
13058        if let Some(module_handle) =
13059            traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
13060        {
13061            graph.add_edge(
13062                &entry.handle,
13063                module_handle,
13064                "enclosing_module",
13065                Some("nearest enclosing module AST span".to_string()),
13066                1,
13067            );
13068        }
13069
13070        if entry.language == "markdown"
13071            && let Some(markdown) = &entry.markdown
13072            && let Some(section_handle) = &markdown.section_handle
13073            && section_handle != &entry.handle
13074        {
13075            graph.add_edge(
13076                section_handle,
13077                &entry.handle,
13078                "contains_markdown_block",
13079                Some("Markdown section contains block".to_string()),
13080                1,
13081            );
13082            graph.add_edge(
13083                &entry.handle,
13084                section_handle,
13085                "enclosing_section",
13086                Some("Markdown enclosing section".to_string()),
13087                1,
13088            );
13089        }
13090    }
13091
13092    for siblings in children_by_parent.values_mut() {
13093        siblings.sort_by(|left, right| {
13094            left.start_byte
13095                .cmp(&right.start_byte)
13096                .then(left.end_byte.cmp(&right.end_byte))
13097                .then(left.kind.cmp(&right.kind))
13098                .then(left.name.cmp(&right.name))
13099                .then(left.node_kind.cmp(&right.node_kind))
13100                .then(left.handle.cmp(&right.handle))
13101        });
13102        for pair in siblings.windows(2) {
13103            let previous = &pair[0];
13104            let next = &pair[1];
13105            graph.add_edge(
13106                &previous.handle,
13107                &next.handle,
13108                "next_sibling",
13109                Some("next AST sibling span".to_string()),
13110                1,
13111            );
13112            graph.add_edge(
13113                &next.handle,
13114                &previous.handle,
13115                "previous_sibling",
13116                Some("previous AST sibling span".to_string()),
13117                1,
13118            );
13119        }
13120    }
13121}
13122
13123fn traversal_markdown_embedded_symbol_node(
13124    root: &Path,
13125    entry: &TraversalAstSpanIndexEntry,
13126    markdown: &MarkdownSpanMetadata,
13127    embedded: &MarkdownEmbeddedSymbol,
13128) -> TraversalNode {
13129    let mut properties = BTreeMap::new();
13130    properties.insert("layer".to_string(), "embedded_code".to_string());
13131    properties.insert("embedded".to_string(), "true".to_string());
13132    properties.insert("language".to_string(), embedded.language.clone());
13133    properties.insert("symbol_kind".to_string(), embedded.kind.clone());
13134    properties.insert("node_kind".to_string(), embedded.node_kind.clone());
13135    properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
13136    properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
13137    properties.insert("end_line".to_string(), embedded.end_line.to_string());
13138    properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
13139    properties.insert(
13140        "markdown_block_kind".to_string(),
13141        markdown_ast_block_kind(&entry.kind),
13142    );
13143    if let Some(body_start_byte) = embedded.body_start_byte {
13144        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
13145    }
13146    if let Some(body_end_byte) = embedded.body_end_byte {
13147        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
13148    }
13149    if let Some(body_start_line) = embedded.body_start_line {
13150        properties.insert("body_start_line".to_string(), body_start_line.to_string());
13151    }
13152    if let Some(body_end_line) = embedded.body_end_line {
13153        properties.insert("body_end_line".to_string(), body_end_line.to_string());
13154    }
13155    if let Some(fence_language) = &markdown.fence_language {
13156        properties.insert("fence_language".to_string(), fence_language.clone());
13157    }
13158    if !markdown.section_path.is_empty() {
13159        properties.insert(
13160            "section_path".to_string(),
13161            markdown.section_path.join(" > "),
13162        );
13163    }
13164    if let Some(section_handle) = &markdown.section_handle {
13165        properties.insert("section_handle".to_string(), section_handle.clone());
13166    }
13167    let line_count = embedded
13168        .end_line
13169        .saturating_sub(embedded.start_line)
13170        .saturating_add(1)
13171        .max(1);
13172    TraversalNode {
13173        handle: embedded.handle.clone(),
13174        kind: "ast_span".to_string(),
13175        label: embedded.name.clone(),
13176        ref_id: Some(embedded.name.clone()),
13177        path: Some(entry.file.clone()),
13178        line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
13179        detail: Some(format!(
13180            "{} {} embedded in Markdown fence",
13181            embedded.language, embedded.kind
13182        )),
13183        properties,
13184        expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
13185    }
13186}
13187
13188fn link_markdown_embedded_code_edges(
13189    graph: &mut TraversalGraphBuild,
13190    root: &Path,
13191    entries: &[TraversalAstSpanIndexEntry],
13192) {
13193    for entry in entries {
13194        let Some(markdown) = &entry.markdown else {
13195            continue;
13196        };
13197        for embedded in &markdown.embedded_symbols {
13198            let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
13199            graph.add_node(node);
13200            graph.add_edge(
13201                &entry.handle,
13202                &embedded.handle,
13203                "contains",
13204                Some("Markdown fence contains embedded AST symbol".to_string()),
13205                1,
13206            );
13207            graph.add_edge(
13208                &entry.handle,
13209                &embedded.handle,
13210                "child",
13211                Some("embedded code symbol".to_string()),
13212                1,
13213            );
13214            graph.add_edge(
13215                &entry.handle,
13216                &embedded.handle,
13217                "contains_embedded_symbol",
13218                Some("Markdown fence contains embedded code symbol".to_string()),
13219                1,
13220            );
13221            graph.add_edge(
13222                &embedded.handle,
13223                &entry.handle,
13224                "parent",
13225                Some("Markdown fence parent span".to_string()),
13226                1,
13227            );
13228            graph.add_edge(
13229                &embedded.handle,
13230                &entry.handle,
13231                "embedded_in_fence",
13232                Some("embedded code symbol belongs to Markdown fence".to_string()),
13233                1,
13234            );
13235            if let Some(section_handle) = &markdown.section_handle
13236                && section_handle != &entry.handle
13237            {
13238                graph.add_edge(
13239                    section_handle,
13240                    &embedded.handle,
13241                    "contains_embedded_code",
13242                    Some("Markdown section contains embedded code symbol".to_string()),
13243                    1,
13244                );
13245                graph.add_edge(
13246                    &embedded.handle,
13247                    section_handle,
13248                    "enclosing_section",
13249                    Some("Markdown enclosing section".to_string()),
13250                    1,
13251                );
13252            }
13253        }
13254    }
13255}
13256
13257fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
13258    let mut tokens = traversal_tokens(&node.label);
13259    if let Some(ref_id) = &node.ref_id {
13260        tokens.extend(traversal_tokens(ref_id));
13261    }
13262    if let Some(path) = &node.path {
13263        tokens.extend(traversal_tokens(path));
13264    }
13265    if let Some(detail) = &node.detail {
13266        tokens.extend(traversal_tokens(detail));
13267    }
13268    tokens
13269}
13270
13271fn markdown_code_spans(input: &str) -> Vec<String> {
13272    input
13273        .split('`')
13274        .enumerate()
13275        .filter(|(idx, _)| idx % 2 == 1)
13276        .map(|(_, part)| part.trim().to_string())
13277        .filter(|part| !part.is_empty())
13278        .collect()
13279}
13280
13281fn push_traversal_token_index(
13282    index: &mut HashMap<String, Vec<usize>>,
13283    tokens: &BTreeSet<String>,
13284    entry_index: usize,
13285) {
13286    for token in tokens {
13287        index.entry(token.clone()).or_default().push(entry_index);
13288    }
13289}
13290
13291impl<'a> TraversalCodeLookup<'a> {
13292    fn new(
13293        symbols: &'a [TraversalSymbolIndexEntry],
13294        files: &'a [TraversalFileIndexEntry],
13295        routes: &'a [TraversalRouteIndexEntry],
13296        multiplicities: &'a [TraversalMultiplicityIndexEntry],
13297    ) -> Self {
13298        let mut symbol_index = HashMap::new();
13299        for (idx, entry) in symbols.iter().enumerate() {
13300            push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
13301        }
13302        let mut file_index = HashMap::new();
13303        let mut file_path_index = HashMap::new();
13304        for (idx, entry) in files.iter().enumerate() {
13305            push_traversal_token_index(&mut file_index, &entry.tokens, idx);
13306            if let Some(path) = entry.node.path.as_ref() {
13307                file_path_index.insert(path.clone(), path.clone());
13308            }
13309        }
13310        let mut route_index = HashMap::new();
13311        for (idx, entry) in routes.iter().enumerate() {
13312            push_traversal_token_index(&mut route_index, &entry.tokens, idx);
13313        }
13314        let mut multiplicity_index = HashMap::new();
13315        for (idx, entry) in multiplicities.iter().enumerate() {
13316            push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
13317        }
13318        Self {
13319            symbols,
13320            files,
13321            routes,
13322            multiplicities,
13323            symbol_index,
13324            file_index,
13325            route_index,
13326            multiplicity_index,
13327            file_path_index,
13328        }
13329    }
13330
13331    fn touched_files_for_line(&self, line: &str) -> Vec<String> {
13332        let mut touched_files = BTreeSet::new();
13333        for candidate in markdown_code_spans(line)
13334            .into_iter()
13335            .chain(line.split_whitespace().map(str::to_string))
13336        {
13337            for path in traversal_path_candidates(&candidate) {
13338                if let Some(file) = self.file_path_index.get(&path) {
13339                    touched_files.insert(file.clone());
13340                }
13341            }
13342        }
13343        touched_files.into_iter().collect()
13344    }
13345}
13346
13347fn traversal_path_candidates(candidate: &str) -> Vec<String> {
13348    let trimmed = candidate.trim_matches(|ch: char| {
13349        matches!(
13350            ch,
13351            '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
13352        )
13353    });
13354    if trimmed.is_empty() {
13355        return Vec::new();
13356    }
13357    let mut candidates = vec![trimmed.to_string()];
13358    if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
13359        && !path.is_empty()
13360        && line_suffix.chars().all(|ch| ch.is_ascii_digit())
13361    {
13362        candidates.push(path.to_string());
13363    }
13364    candidates
13365}
13366
13367fn parse_worker_result_line(
13368    line: &str,
13369    lookup: &TraversalCodeLookup<'_>,
13370) -> Vec<ParsedWorkerResult> {
13371    if line.trim_start().starts_with("- [") {
13372        return Vec::new();
13373    }
13374    let lower = line.to_ascii_lowercase();
13375    let status =
13376        if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
13377        {
13378            "completed"
13379        } else if lower.contains("blocked") || lower.contains("externally blocked") {
13380            "blocked"
13381        } else {
13382            return Vec::new();
13383        };
13384    let result_prefix_end = ["follow-up", "follow up", "next:"]
13385        .iter()
13386        .filter_map(|marker| lower.find(marker))
13387        .min()
13388        .unwrap_or(line.len());
13389    let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
13390    if ids.is_empty() {
13391        return Vec::new();
13392    }
13393    let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
13394    let all_ids = extract_conflict_target_refs(line);
13395
13396    let touched_files = lookup.touched_files_for_line(line);
13397    let tests = markdown_code_spans(line)
13398        .into_iter()
13399        .filter(|span| span.to_ascii_lowercase().contains("test"))
13400        .collect::<Vec<_>>();
13401
13402    ids.iter()
13403        .map(|id| ParsedWorkerResult {
13404            id: id.clone(),
13405            status: status.to_string(),
13406            touched_files: touched_files.clone(),
13407            tests: tests.clone(),
13408            follow_up_ids: all_ids
13409                .iter()
13410                .filter(|other| *other != id && !result_ids.contains(*other))
13411                .cloned()
13412                .collect(),
13413        })
13414        .collect()
13415}
13416
13417fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
13418    let hinted_path = if path_hint.is_absolute() {
13419        path_hint.to_path_buf()
13420    } else {
13421        root.join(path_hint)
13422    };
13423    if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
13424        return Some(hinted_path);
13425    }
13426    None
13427}
13428
13429fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
13430    let candidate = if path.is_absolute() {
13431        path.to_path_buf()
13432    } else {
13433        source_root.join(path)
13434    };
13435    if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
13436        return false;
13437    }
13438    if !matches!(
13439        candidate.extension().and_then(|ext| ext.to_str()),
13440        Some("md" | "mdx")
13441    ) {
13442        return false;
13443    }
13444    fs::read_to_string(&candidate)
13445        .map(|content| session_markdown::markdown_content_looks_like_agent_doc_session(&content))
13446        .unwrap_or(false)
13447}
13448
13449fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
13450    if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
13451        return Ok(vec![hinted_path]);
13452    }
13453    let mut files = Vec::new();
13454    let walker = ignore::WalkBuilder::new(root)
13455        .hidden(true)
13456        .git_ignore(true)
13457        .git_global(true)
13458        .git_exclude(true)
13459        .build();
13460    for result in walker {
13461        let entry =
13462            result.with_context(|| format!("walking markdown files under {}", root.display()))?;
13463        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
13464            continue;
13465        }
13466        if traversal_path_is_generated_artifact(root, root, entry.path()) {
13467            continue;
13468        }
13469        if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
13470            files.push(entry.path().to_path_buf());
13471        }
13472    }
13473    files.sort();
13474    Ok(files)
13475}
13476
13477fn traversal_watermark_path(root: &Path, path: &Path) -> String {
13478    path.strip_prefix(root)
13479        .unwrap_or(path)
13480        .to_string_lossy()
13481        .replace('\\', "/")
13482}
13483
13484fn push_traversal_metadata_watermark_part(
13485    root: &Path,
13486    path: &Path,
13487    label: &str,
13488    parts: &mut Vec<String>,
13489) {
13490    let display = traversal_watermark_path(root, path);
13491    match fs::metadata(path) {
13492        Ok(metadata) => {
13493            let (secs, nanos) = metadata
13494                .modified()
13495                .ok()
13496                .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
13497                .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
13498                .unwrap_or((0, 0));
13499            parts.push(format!(
13500                "{label}:{display}:len={}:mtime={secs}.{nanos}",
13501                metadata.len()
13502            ));
13503        }
13504        Err(_) => parts.push(format!("{label}:{display}:missing")),
13505    }
13506}
13507
13508#[derive(Serialize)]
13509struct TraversalSummaryWatermarkRow<'a> {
13510    symbol_name: &'a str,
13511    file_path: &'a str,
13512    entities: &'a Option<Vec<summarize::Entity>>,
13513    relationships: &'a Option<Vec<summarize::Relationship>>,
13514    concept_labels: &'a Option<Vec<String>>,
13515}
13516
13517fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
13518    let summaries_db = root.join(".tsift/summaries.db");
13519    if !summaries_db.exists() {
13520        parts.push("summaries_db:absent".to_string());
13521        return Ok(());
13522    }
13523
13524    match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
13525        .and_then(|summary_db| summary_db.all())
13526    {
13527        Ok(summaries) => {
13528            let rows = summaries
13529                .iter()
13530                .map(|summary| TraversalSummaryWatermarkRow {
13531                    symbol_name: &summary.symbol_name,
13532                    file_path: &summary.file_path,
13533                    entities: &summary.entities,
13534                    relationships: &summary.relationships,
13535                    concept_labels: &summary.concept_labels,
13536                })
13537                .collect::<Vec<_>>();
13538            parts.push(format!(
13539                "summaries_db:rows={}:semantic_hash={}",
13540                rows.len(),
13541                content_hash(&rows)?
13542            ));
13543        }
13544        Err(_) => {
13545            push_traversal_metadata_watermark_part(
13546                root,
13547                &summaries_db,
13548                "summaries_db_unreadable",
13549                parts,
13550            );
13551        }
13552    }
13553    Ok(())
13554}
13555
13556#[cfg(test)]
13557fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
13558    resolution::relative_path_is_generated_artifact(relative)
13559}
13560
13561fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
13562    resolution::path_is_generated_artifact(root, source_root, path)
13563}
13564
13565fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
13566    resolution::index_snapshot_part_is_generated(root, source_root, part)
13567}
13568
13569pub(crate) fn traversal_source_watermark(
13570    root: &Path,
13571    path_hint: &Path,
13572    scope: Option<&str>,
13573    session_only: bool,
13574) -> Result<Option<String>> {
13575    let mut parts = vec![
13576        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
13577        format!("scope:{}", scope.unwrap_or("root")),
13578        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
13579        format!("session_only:{session_only}"),
13580    ];
13581
13582    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13583        let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13584            Ok(targets) => targets,
13585            Err(_) => return Ok(None),
13586        };
13587        let Some(target) = targets.into_iter().next() else {
13588            return Ok(None);
13589        };
13590        let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13591            Ok(db) => db,
13592            Err(_) => return Ok(None),
13593        };
13594        parts.push(format!("index_label:{}", target.label));
13595        parts.push(format!(
13596            "index_scope:{}",
13597            target.scope_name.as_deref().unwrap_or("root")
13598        ));
13599        parts.push(format!(
13600            "index_source_root:{}",
13601            traversal_watermark_path(root, &target.source_root)
13602        ));
13603        let mut snapshot_rows = 0usize;
13604        for part in db.source_snapshot_parts()? {
13605            if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13606                continue;
13607            }
13608            snapshot_rows += 1;
13609            parts.push(format!("index_snapshot:{part}"));
13610        }
13611        parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13612    }
13613
13614    let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13615    parts.push(format!("markdown_count:{}", markdown_files.len()));
13616    for markdown_path in markdown_files {
13617        push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13618    }
13619
13620    push_traversal_summaries_watermark_part(root, &mut parts)?;
13621
13622    Ok(Some(content_hash(&parts)?))
13623}
13624
13625fn ranked_symbol_matches<'a>(
13626    query_tokens: &BTreeSet<String>,
13627    entries: &'a [TraversalSymbolIndexEntry],
13628    index: &HashMap<String, Vec<usize>>,
13629) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13630    let mut scores = BTreeMap::<usize, usize>::new();
13631    for token in query_tokens {
13632        if let Some(indices) = index.get(token) {
13633            for idx in indices {
13634                *scores.entry(*idx).or_default() += 1;
13635            }
13636        }
13637    }
13638    let mut matches = scores
13639        .into_iter()
13640        .map(|(idx, score)| (score, &entries[idx]))
13641        .collect::<Vec<_>>();
13642    matches.sort_by(|(left_score, left), (right_score, right)| {
13643        right_score
13644            .cmp(left_score)
13645            .then_with(|| left.node.label.cmp(&right.node.label))
13646            .then_with(|| left.handle.cmp(&right.handle))
13647    });
13648    matches
13649}
13650
13651fn ranked_file_matches<'a>(
13652    query_tokens: &BTreeSet<String>,
13653    entries: &'a [TraversalFileIndexEntry],
13654    index: &HashMap<String, Vec<usize>>,
13655) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13656    let mut scores = BTreeMap::<usize, usize>::new();
13657    for token in query_tokens {
13658        if let Some(indices) = index.get(token) {
13659            for idx in indices {
13660                *scores.entry(*idx).or_default() += 1;
13661            }
13662        }
13663    }
13664    let mut matches = scores
13665        .into_iter()
13666        .map(|(idx, score)| (score, &entries[idx]))
13667        .collect::<Vec<_>>();
13668    matches.sort_by(|(left_score, left), (right_score, right)| {
13669        right_score
13670            .cmp(left_score)
13671            .then_with(|| left.node.label.cmp(&right.node.label))
13672            .then_with(|| left.handle.cmp(&right.handle))
13673    });
13674    matches
13675}
13676
13677fn ranked_route_matches<'a>(
13678    query_tokens: &BTreeSet<String>,
13679    entries: &'a [TraversalRouteIndexEntry],
13680    index: &HashMap<String, Vec<usize>>,
13681) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13682    let mut scores = BTreeMap::<usize, usize>::new();
13683    for token in query_tokens {
13684        if let Some(indices) = index.get(token) {
13685            for idx in indices {
13686                *scores.entry(*idx).or_default() += 1;
13687            }
13688        }
13689    }
13690    let mut matches = scores
13691        .into_iter()
13692        .map(|(idx, score)| (score, &entries[idx]))
13693        .collect::<Vec<_>>();
13694    matches.sort_by(|(left_score, left), (right_score, right)| {
13695        right_score
13696            .cmp(left_score)
13697            .then_with(|| left.node.label.cmp(&right.node.label))
13698            .then_with(|| left.handle.cmp(&right.handle))
13699    });
13700    matches
13701}
13702
13703fn ranked_multiplicity_matches<'a>(
13704    query_tokens: &BTreeSet<String>,
13705    entries: &'a [TraversalMultiplicityIndexEntry],
13706    index: &HashMap<String, Vec<usize>>,
13707) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13708    let mut scores = BTreeMap::<usize, usize>::new();
13709    for token in query_tokens {
13710        if let Some(indices) = index.get(token) {
13711            for idx in indices {
13712                *scores.entry(*idx).or_default() += 1;
13713            }
13714        }
13715    }
13716    let mut matches = scores
13717        .into_iter()
13718        .map(|(idx, score)| (score, &entries[idx]))
13719        .collect::<Vec<_>>();
13720    matches.sort_by(|(left_score, left), (right_score, right)| {
13721        right_score
13722            .cmp(left_score)
13723            .then_with(|| left.node.kind.cmp(&right.node.kind))
13724            .then_with(|| left.node.label.cmp(&right.node.label))
13725            .then_with(|| left.handle.cmp(&right.handle))
13726    });
13727    matches
13728}
13729
13730fn link_backlog_to_code_nodes(
13731    graph: &mut TraversalGraphBuild,
13732    backlog: &TraversalNode,
13733    text: &str,
13734    lookup: &TraversalCodeLookup<'_>,
13735    limit: usize,
13736) {
13737    let mut query_tokens = traversal_tokens(text);
13738    if let Some(ref_id) = &backlog.ref_id {
13739        query_tokens.extend(traversal_tokens(ref_id));
13740    }
13741    if query_tokens.is_empty() {
13742        return;
13743    }
13744
13745    for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13746        .into_iter()
13747        .take(limit)
13748    {
13749        graph.add_edge(
13750            &backlog.handle,
13751            &entry.handle,
13752            "mentions",
13753            Some("backlog text matches symbol tokens".to_string()),
13754            score,
13755        );
13756    }
13757
13758    for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13759        .into_iter()
13760        .take(limit.min(5))
13761    {
13762        graph.add_edge(
13763            &backlog.handle,
13764            &entry.handle,
13765            "mentions",
13766            Some("backlog text matches file tokens".to_string()),
13767            score,
13768        );
13769    }
13770
13771    for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13772        .into_iter()
13773        .take(limit.min(5))
13774    {
13775        graph.add_edge(
13776            &backlog.handle,
13777            &entry.handle,
13778            "mentions",
13779            Some("backlog text matches route tokens".to_string()),
13780            score,
13781        );
13782    }
13783
13784    for (score, entry) in ranked_multiplicity_matches(
13785        &query_tokens,
13786        lookup.multiplicities,
13787        &lookup.multiplicity_index,
13788    )
13789    .into_iter()
13790    .take(limit.min(5))
13791    {
13792        graph.add_edge(
13793            &backlog.handle,
13794            &entry.handle,
13795            "mentions",
13796            Some("backlog text matches multiplicity tokens".to_string()),
13797            score,
13798        );
13799    }
13800}
13801
13802fn load_agent_doc_traversal_nodes(
13803    root: &Path,
13804    path_hint: &Path,
13805    graph: &mut TraversalGraphBuild,
13806    lookup: &TraversalCodeLookup<'_>,
13807) -> Result<()> {
13808    for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13809        let content = match fs::read_to_string(&markdown_path) {
13810            Ok(content) => content,
13811            Err(err) => {
13812                graph.warnings.push(format!(
13813                    "session artifact unavailable: {}: {err}",
13814                    markdown_path.display()
13815                ));
13816                continue;
13817            }
13818        };
13819        let Some(document) = AgentDocSessionDocument::parse_if_session(&content) else {
13820            continue;
13821        };
13822
13823        let session = traversal_session_node(root, &markdown_path, document.session_id.as_deref());
13824        graph.add_node(session.clone());
13825        let lines = content.lines().collect::<Vec<_>>();
13826        let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13827        for item in &document.backlog_items {
13828            let backlog = traversal_backlog_node(
13829                root,
13830                &markdown_path,
13831                &item.id,
13832                &item.text,
13833                item.line as i64,
13834            );
13835            graph.add_node(backlog.clone());
13836            backlog_by_id.insert(item.id.clone(), backlog.clone());
13837            graph.add_edge(
13838                &session.handle,
13839                &backlog.handle,
13840                "contains",
13841                Some("session backlog item".to_string()),
13842                1,
13843            );
13844            link_backlog_to_code_nodes(graph, &backlog, &item.text, lookup, 8);
13845        }
13846
13847        let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13848        for item in &document.queue_items {
13849            match item {
13850                AgentDocQueueItem::Dispatch { value, line }
13851                | AgentDocQueueItem::Preset { value, line } => {
13852                    let dispatch_ref = value.strip_prefix('#').unwrap_or(value.as_str());
13853                    let node = traversal_job_packet_node(
13854                        root,
13855                        &markdown_path,
13856                        &format!("dispatch {value}"),
13857                        Some(dispatch_ref),
13858                        "agent-doc dispatch preset",
13859                        *line as i64,
13860                    );
13861                    graph.add_node(node.clone());
13862                    graph.add_edge(
13863                        &session.handle,
13864                        &node.handle,
13865                        "contains",
13866                        Some("session queued dispatch".to_string()),
13867                        1,
13868                    );
13869                }
13870                AgentDocQueueItem::Do { id, line } => {
13871                    let detail = backlog_by_id
13872                        .get(id)
13873                        .and_then(|node| node.detail.clone())
13874                        .unwrap_or_else(|| "queued backlog item".to_string());
13875                    let node = traversal_job_packet_node(
13876                        root,
13877                        &markdown_path,
13878                        &format!("do #{id}"),
13879                        Some(id),
13880                        &detail,
13881                        *line as i64,
13882                    );
13883                    graph.add_node(node.clone());
13884                    graph.add_edge(
13885                        &session.handle,
13886                        &node.handle,
13887                        "contains",
13888                        Some("session queued job packet".to_string()),
13889                        1,
13890                    );
13891                    if let Some(backlog) = backlog_by_id.get(id) {
13892                        graph.add_edge(
13893                            &node.handle,
13894                            &backlog.handle,
13895                            "targets",
13896                            Some("queued backlog item".to_string()),
13897                            1,
13898                        );
13899                    }
13900                    job_by_id.insert(id.clone(), node);
13901                }
13902            }
13903        }
13904
13905        let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13906        for (idx, line) in lines.iter().enumerate() {
13907            for parsed in parse_worker_result_line(line, lookup) {
13908                let line_no = idx as i64 + 1;
13909                if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13910                    continue;
13911                }
13912                let result =
13913                    traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13914                graph.add_node(result.clone());
13915                graph.add_edge(
13916                    &session.handle,
13917                    &result.handle,
13918                    "contains",
13919                    Some("session worker result".to_string()),
13920                    1,
13921                );
13922                if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13923                    graph.add_edge(
13924                        &backlog.handle,
13925                        &result.handle,
13926                        "has_result",
13927                        Some(format!("worker result {}", parsed.status)),
13928                        1,
13929                    );
13930                }
13931                if let Some(job) = job_by_id.get(&parsed.id) {
13932                    graph.add_edge(
13933                        &job.handle,
13934                        &result.handle,
13935                        "has_result",
13936                        Some(format!("queued worker result {}", parsed.status)),
13937                        1,
13938                    );
13939                }
13940                let mut result_text = line.to_string();
13941                if !parsed.touched_files.is_empty() {
13942                    result_text.push(' ');
13943                    result_text.push_str(&parsed.touched_files.join(" "));
13944                }
13945                link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13946            }
13947        }
13948    }
13949    Ok(())
13950}
13951
13952#[derive(Debug, Clone)]
13953struct AgentDocIndexGate {
13954    db_path: Option<PathBuf>,
13955    source_root: PathBuf,
13956    diagnostics: Vec<String>,
13957}
13958
13959#[derive(Clone, Hash, PartialEq, Eq)]
13960struct AgentDocIndexGateCacheKey {
13961    root: PathBuf,
13962    path_hint: PathBuf,
13963    scope: Option<String>,
13964    packet_label: String,
13965}
13966
13967fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13968    std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13969> {
13970    static CACHE: std::sync::OnceLock<
13971        std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13972    > = std::sync::OnceLock::new();
13973    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13974}
13975
13976fn prepare_agent_doc_index_gate_cached(
13977    root: &Path,
13978    path_hint: &Path,
13979    scope: Option<&str>,
13980    packet_label: &str,
13981) -> (AgentDocIndexGate, String) {
13982    let key = AgentDocIndexGateCacheKey {
13983        root: root.to_path_buf(),
13984        path_hint: path_hint.to_path_buf(),
13985        scope: scope.map(str::to_string),
13986        packet_label: packet_label.to_string(),
13987    };
13988    if let Ok(cache) = agent_doc_index_gate_cache().lock()
13989        && let Some(cached) = cache.get(&key)
13990    {
13991        return (
13992            cached.clone(),
13993            "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13994        );
13995    }
13996    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13997    if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13998        cache.insert(key, gate.clone());
13999    }
14000    (
14001        gate,
14002        "fresh inspection/refresh — cache miss on this preparation key".to_string(),
14003    )
14004}
14005
14006fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
14007    match state {
14008        SearchIndexState::Fresh => None,
14009        SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
14010        SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
14011    }
14012}
14013
14014fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
14015    rebuild_search_target_detail(&RebuildSearchTarget {
14016        label: target.label.clone(),
14017        reason,
14018        reindex_cmd: target.reindex_cmd.clone(),
14019    })
14020}
14021
14022fn index_refresh_diagnostic(
14023    target: &SearchIndexTarget,
14024    reason: RebuildSearchReason,
14025    summary: &index::IndexSummary,
14026    packet_label: &str,
14027) -> String {
14028    let changed = summary.new + summary.modified + summary.deleted;
14029    format!(
14030        "index refreshed: {}; updated {} changed file{} before {}",
14031        index_reason_detail(target, reason),
14032        changed,
14033        if changed == 1 { "" } else { "s" },
14034        packet_label
14035    )
14036}
14037
14038fn index_refresh_fallback_diagnostic(
14039    target: &SearchIndexTarget,
14040    reason: RebuildSearchReason,
14041    err: &anyhow::Error,
14042    packet_label: &str,
14043) -> String {
14044    format!(
14045        "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
14046        index_reason_detail(target, reason),
14047        packet_label
14048    )
14049}
14050
14051fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
14052    if let Some(scope_name) = scope
14053        && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
14054    {
14055        return scope.source_root;
14056    }
14057    if let Some(scope_name) = scope
14058        && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
14059    {
14060        return package.package_root;
14061    }
14062    if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
14063        return scope.source_root;
14064    }
14065    if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
14066        return package.package_root;
14067    }
14068    if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
14069        return scope.source_root;
14070    }
14071    root.to_path_buf()
14072}
14073
14074fn prepare_agent_doc_index_gate(
14075    root: &Path,
14076    path_hint: &Path,
14077    scope: Option<&str>,
14078    packet_label: &str,
14079) -> AgentDocIndexGate {
14080    let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
14081    let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
14082        Ok(targets) => targets,
14083        Err(err) => {
14084            return AgentDocIndexGate {
14085                db_path: None,
14086                source_root: fallback_source_root,
14087                diagnostics: vec![format!(
14088                    "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14089                )],
14090            };
14091        }
14092    };
14093    let Some(target) = targets.into_iter().next() else {
14094        return AgentDocIndexGate {
14095            db_path: None,
14096            source_root: fallback_source_root,
14097            diagnostics: vec![format!(
14098                "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
14099            )],
14100        };
14101    };
14102
14103    let state = match inspect_search_index(&target) {
14104        Ok(state) => state,
14105        Err(err) => {
14106            return AgentDocIndexGate {
14107                db_path: None,
14108                source_root: target.source_root,
14109                diagnostics: vec![format!(
14110                    "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14111                )],
14112            };
14113        }
14114    };
14115
14116    let Some(reason) = index_reason_for_state(state) else {
14117        return AgentDocIndexGate {
14118            db_path: Some(target.db_path),
14119            source_root: target.source_root,
14120            diagnostics: Vec::new(),
14121        };
14122    };
14123
14124    match apply_search_index_update(root, &target) {
14125        Ok(summary) => {
14126            // #gdbgatecold: the index was just rewritten, so any cached
14127            // pre-refresh inspection result for this scope (held by the
14128            // active lazily-backed `InspectScopeGuard`) is stale. Invalidate
14129            // the scope epoch so the next `inspect_read_only` re-reads the
14130            // fresh index.
14131            index::inspect_scope_invalidate_all();
14132            let diagnostics = vec![index_refresh_diagnostic(
14133                &target,
14134                reason,
14135                &summary,
14136                packet_label,
14137            )];
14138            AgentDocIndexGate {
14139                db_path: Some(target.db_path),
14140                source_root: target.source_root,
14141                diagnostics,
14142            }
14143        }
14144        Err(err) => {
14145            let diagnostics = vec![index_refresh_fallback_diagnostic(
14146                &target,
14147                reason,
14148                &err,
14149                packet_label,
14150            )];
14151            AgentDocIndexGate {
14152                db_path: None,
14153                source_root: target.source_root,
14154                diagnostics,
14155            }
14156        }
14157    }
14158}
14159
14160fn add_raw_source_file_nodes(
14161    root: &Path,
14162    source_root: &Path,
14163    graph: &mut TraversalGraphBuild,
14164    file_entries: &mut Vec<TraversalFileIndexEntry>,
14165) -> Result<()> {
14166    let mut entries = walk::walk_files(source_root)?;
14167    entries.sort_by(|left, right| left.path.cmp(&right.path));
14168    for entry in entries {
14169        let file = entry.path.to_string_lossy();
14170        let node = traversal_raw_source_file_node(root, file.as_ref());
14171        let entry = TraversalFileIndexEntry {
14172            handle: node.handle.clone(),
14173            tokens: traversal_node_tokens(&node),
14174            node: node.clone(),
14175        };
14176        graph.add_node(node);
14177        file_entries.push(entry);
14178    }
14179    Ok(())
14180}
14181
14182fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
14183    if scope_root.is_empty() {
14184        return true;
14185    }
14186    path == scope_root || path.starts_with(&format!("{scope_root}/"))
14187}
14188
14189fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
14190    let path = Path::new(file);
14191    if path.is_absolute() {
14192        return path.to_path_buf();
14193    }
14194    let source_candidate = source_root.join(path);
14195    if source_candidate.exists() {
14196        source_candidate
14197    } else {
14198        root.join(path)
14199    }
14200}
14201
14202fn cargo_import_alias_from_line(line: &str) -> Option<String> {
14203    let trimmed = line.trim();
14204    let rest = trimmed
14205        .strip_prefix("pub use ")
14206        .or_else(|| trimmed.strip_prefix("use "))
14207        .or_else(|| trimmed.strip_prefix("extern crate "))?;
14208    let alias = rest
14209        .split([':', ';', ' ', '\t'])
14210        .next()
14211        .unwrap_or_default()
14212        .trim();
14213    (!alias.is_empty()).then(|| alias.to_string())
14214}
14215
14216fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
14217    let mut aliases = BTreeSet::new();
14218    for entry in walk::walk_files(&package.package_root)? {
14219        if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
14220            continue;
14221        }
14222        let content = fs::read_to_string(&entry.path)
14223            .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
14224        aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
14225    }
14226    Ok(aliases)
14227}
14228
14229fn load_multiplicity_traversal_nodes(
14230    root: &Path,
14231    source_root: &Path,
14232    graph: &mut TraversalGraphBuild,
14233    file_handle_by_path: &HashMap<String, String>,
14234    multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
14235) -> Result<()> {
14236    let inventory = multiplicity::discover_cargo_inventory(source_root)?;
14237    let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
14238    for workspace in &inventory.workspaces {
14239        let node = traversal_cargo_workspace_node(root, workspace);
14240        workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
14241        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14242            handle: node.handle.clone(),
14243            tokens: traversal_node_tokens(&node),
14244            node: node.clone(),
14245        });
14246        graph.add_node(node);
14247    }
14248
14249    let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
14250    let mut package_nodes = Vec::new();
14251    for package in &inventory.packages {
14252        let node = traversal_cargo_package_node(root, package);
14253        package_handle_by_name
14254            .entry(package.name.clone())
14255            .or_default()
14256            .push(node.handle.clone());
14257        package_handle_by_name
14258            .entry(package.normalized_name.clone())
14259            .or_default()
14260            .push(node.handle.clone());
14261        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14262            handle: node.handle.clone(),
14263            tokens: traversal_node_tokens(&node),
14264            node: node.clone(),
14265        });
14266        graph.add_node(node.clone());
14267        package_nodes.push((package, node));
14268    }
14269
14270    for (package, node) in &package_nodes {
14271        if let Some(workspace_handle) =
14272            workspace_handle_by_root.get(&package.relative_workspace_root)
14273        {
14274            graph.add_edge(
14275                workspace_handle,
14276                &node.handle,
14277                "contains_package",
14278                Some("Cargo workspace member package".to_string()),
14279                1,
14280            );
14281        }
14282        let package_root = relativize_pathbuf(&package.package_root, root)
14283            .to_string_lossy()
14284            .replace('\\', "/");
14285        for (file, handle) in file_handle_by_path {
14286            if relative_path_inside_scope(file, &package_root) {
14287                graph.add_edge(
14288                    &node.handle,
14289                    handle,
14290                    "owns_file",
14291                    Some("Cargo package owns source file".to_string()),
14292                    1,
14293                );
14294            }
14295        }
14296        for dependency in &package.dependencies {
14297            if let Some(handles) = package_handle_by_name.get(&dependency.name)
14298                && handles.len() == 1
14299            {
14300                graph.add_edge(
14301                    &node.handle,
14302                    &handles[0],
14303                    "declares_dependency",
14304                    Some(format!("{} Cargo dependency", dependency.kind)),
14305                    1,
14306                );
14307            }
14308        }
14309        for alias in cargo_import_aliases(package)? {
14310            if let Some(handles) = package_handle_by_name.get(&alias)
14311                && handles.len() == 1
14312                && handles[0] != node.handle
14313            {
14314                graph.add_edge(
14315                    &node.handle,
14316                    &handles[0],
14317                    "uses_crate",
14318                    Some("Rust use/extern crate reference".to_string()),
14319                    1,
14320                );
14321                graph.add_edge(
14322                    &node.handle,
14323                    &handles[0],
14324                    "imports",
14325                    Some("Rust use/extern crate import".to_string()),
14326                    1,
14327                );
14328            }
14329        }
14330    }
14331
14332    Ok(())
14333}
14334
14335fn build_traversal_graph_source_with_options(
14336    root: &Path,
14337    path_hint: &Path,
14338    scope: Option<&str>,
14339    session_only: bool,
14340) -> Result<TraversalGraphBuild> {
14341    let mut graph = TraversalGraphBuild::default();
14342    let mut symbol_entries = Vec::new();
14343    let mut file_entries = Vec::new();
14344    let mut route_entries = Vec::new();
14345    let mut multiplicity_entries = Vec::new();
14346    let mut file_handle_by_path = HashMap::<String, String>::new();
14347    let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
14348    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
14349        let (gate, _cache_detail) =
14350            prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
14351        graph.warnings.extend(gate.diagnostics);
14352        let gate_source_root = gate.source_root.clone();
14353
14354        match gate.db_path {
14355            Some(db_path) if db_path.exists() => {
14356                let db = index::IndexDb::open_read_only_resilient(&db_path)?;
14357                let file_paths = db.file_paths()?;
14358                for file in file_paths {
14359                    if traversal_path_is_generated_artifact(
14360                        root,
14361                        &gate_source_root,
14362                        Path::new(&file),
14363                    ) {
14364                        continue;
14365                    }
14366                    let node = traversal_file_node(root, &file);
14367                    let entry = TraversalFileIndexEntry {
14368                        handle: node.handle.clone(),
14369                        tokens: traversal_node_tokens(&node),
14370                        node: node.clone(),
14371                    };
14372                    if let Some(path) = entry.node.path.as_ref() {
14373                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
14374                    }
14375                    graph.add_node(node);
14376                    file_entries.push(entry);
14377                }
14378
14379                let symbols = db.all_symbols()?;
14380                // AST parent/child lookup is per-file; using the root symbol set
14381                // here turns large graph refreshes into quadratic full-index scans.
14382                let mut symbols_by_file = HashMap::<String, Vec<&index::StoredSymbol>>::new();
14383                for symbol in &symbols {
14384                    symbols_by_file
14385                        .entry(symbol.file.clone())
14386                        .or_default()
14387                        .push(symbol);
14388                }
14389                let mut symbol_by_file_name_line = HashMap::new();
14390                let mut span_by_file_name_line = HashMap::new();
14391                let mut first_symbol_by_name = BTreeMap::<String, String>::new();
14392                let mut first_span_by_name = BTreeMap::<String, String>::new();
14393                let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
14394                let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
14395                for symbol in symbols.iter().filter(|symbol| {
14396                    !traversal_path_is_generated_artifact(
14397                        root,
14398                        &gate_source_root,
14399                        Path::new(&symbol.file),
14400                    )
14401                }) {
14402                    let node = traversal_symbol_node(root, symbol);
14403                    let file = relativize(&symbol.file, root);
14404                    symbol_by_file_name_line.insert(
14405                        format!("{file}:{}:{}", symbol.line, symbol.name),
14406                        node.handle.clone(),
14407                    );
14408                    first_symbol_by_name
14409                        .entry(symbol.name.clone())
14410                        .or_insert_with(|| node.handle.clone());
14411                    let entry = TraversalSymbolIndexEntry {
14412                        handle: node.handle.clone(),
14413                        tokens: traversal_node_tokens(&node),
14414                        node: node.clone(),
14415                    };
14416                    graph.add_node(node.clone());
14417                    if let Some(file_handle) = file_handle_by_path.get(&file) {
14418                        graph.add_edge(
14419                            file_handle,
14420                            &node.handle,
14421                            "defines",
14422                            Some("file defines symbol".to_string()),
14423                            1,
14424                        );
14425                    }
14426                    if !source_by_file.contains_key(&symbol.file) {
14427                        let source_path =
14428                            traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
14429                        source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
14430                    }
14431                    if let Some(Some(source)) = source_by_file.get(&symbol.file)
14432                        && let Some((ast_node, mut ast_entry)) =
14433                            traversal_ast_span_node(
14434                                root,
14435                                symbol,
14436                                source,
14437                                symbols_by_file
14438                                    .get(&symbol.file)
14439                                    .map(Vec::as_slice)
14440                                    .unwrap_or(&[]),
14441                            )
14442                    {
14443                        ast_entry.symbol_handle = node.handle.clone();
14444                        ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
14445                        span_by_file_name_line.insert(
14446                            format!("{file}:{}:{}", symbol.line, symbol.name),
14447                            ast_node.handle.clone(),
14448                        );
14449                        first_span_by_name
14450                            .entry(symbol.name.clone())
14451                            .or_insert_with(|| ast_node.handle.clone());
14452                        graph.add_node(ast_node.clone());
14453                        graph.add_edge(
14454                            &node.handle,
14455                            &ast_node.handle,
14456                            "has_ast_span",
14457                            Some("symbol projects to indexed AST span".to_string()),
14458                            1,
14459                        );
14460                        graph.add_edge(
14461                            &ast_node.handle,
14462                            &node.handle,
14463                            "represents_symbol",
14464                            Some("AST span represents indexed symbol".to_string()),
14465                            1,
14466                        );
14467                        ast_entries.push(ast_entry);
14468                    }
14469                    symbol_entries.push(entry);
14470                }
14471                link_ast_navigation_edges(&mut graph, &ast_entries);
14472                link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
14473
14474                if !bounded_session_projection {
14475                    for edge in db.all_stored_edges()? {
14476                        if traversal_path_is_generated_artifact(
14477                            root,
14478                            &gate_source_root,
14479                            Path::new(&edge.caller_file),
14480                        ) {
14481                            continue;
14482                        }
14483                        let caller_file = relativize(&edge.caller_file, root);
14484                        let caller_key =
14485                            format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
14486                        let Some(caller_handle) =
14487                            symbol_by_file_name_line.get(&caller_key).cloned()
14488                        else {
14489                            continue;
14490                        };
14491                        let callee_handle = if let Some(handle) =
14492                            first_symbol_by_name.get(&edge.callee_name)
14493                        {
14494                            handle.clone()
14495                        } else {
14496                            let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
14497                            let handle = node.handle.clone();
14498                            graph.add_node(node);
14499                            handle
14500                        };
14501                        graph.add_edge(
14502                            &caller_handle,
14503                            &callee_handle,
14504                            "calls",
14505                            Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
14506                            1,
14507                        );
14508                        if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
14509                            && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
14510                        {
14511                            graph.add_edge(
14512                                caller_span,
14513                                callee_span,
14514                                "calls",
14515                                Some(format!(
14516                                    "AST call site {}:{}",
14517                                    caller_file, edge.call_site_line
14518                                )),
14519                                1,
14520                            );
14521                        }
14522                    }
14523                }
14524
14525                for route in db.all_routes()? {
14526                    if traversal_path_is_generated_artifact(
14527                        root,
14528                        &gate_source_root,
14529                        Path::new(&route.file),
14530                    ) {
14531                        continue;
14532                    }
14533                    let node = traversal_route_node(root, &route);
14534                    let entry = TraversalRouteIndexEntry {
14535                        handle: node.handle.clone(),
14536                        tokens: traversal_node_tokens(&node),
14537                        node: node.clone(),
14538                    };
14539                    graph.add_node(node.clone());
14540                    if let Some(path) = node.path.as_ref()
14541                        && let Some(file_handle) = file_handle_by_path.get(path)
14542                    {
14543                        graph.add_edge(
14544                            file_handle,
14545                            &node.handle,
14546                            "defines_route",
14547                            Some("file declares route".to_string()),
14548                            1,
14549                        );
14550                    }
14551                    let handler_handle =
14552                        if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
14553                            handle.clone()
14554                        } else {
14555                            let node = traversal_unresolved_symbol_node(root, &route.handler_name);
14556                            let handle = node.handle.clone();
14557                            graph.add_node(node);
14558                            handle
14559                        };
14560                    graph.add_edge(
14561                        &entry.handle,
14562                        &handler_handle,
14563                        "handled_by",
14564                        Some("route handler reference".to_string()),
14565                        1,
14566                    );
14567                    if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
14568                        graph.add_edge(
14569                            &entry.handle,
14570                            handler_span,
14571                            "handled_by",
14572                            Some("route handler AST span".to_string()),
14573                            1,
14574                        );
14575                        graph.add_edge(
14576                            handler_span,
14577                            &entry.handle,
14578                            "handles_route",
14579                            Some("AST span handles route".to_string()),
14580                            1,
14581                        );
14582                    }
14583                    route_entries.push(entry);
14584                }
14585            }
14586            _ => {
14587                add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
14588                    .with_context(|| {
14589                    format!(
14590                        "loading raw source fallback nodes from {}",
14591                        gate_source_root.display()
14592                    )
14593                })?;
14594                for entry in &file_entries {
14595                    if let Some(path) = entry.node.path.as_ref() {
14596                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
14597                    }
14598                }
14599            }
14600        }
14601        load_multiplicity_traversal_nodes(
14602            root,
14603            &gate_source_root,
14604            &mut graph,
14605            &file_handle_by_path,
14606            &mut multiplicity_entries,
14607        )?;
14608    }
14609
14610    let code_lookup = TraversalCodeLookup::new(
14611        &symbol_entries,
14612        &file_entries,
14613        &route_entries,
14614        &multiplicity_entries,
14615    );
14616    load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14617    Ok(graph)
14618}
14619
14620#[cfg(test)]
14621fn build_traversal_graph_source(
14622    root: &Path,
14623    path_hint: &Path,
14624    scope: Option<&str>,
14625) -> Result<TraversalGraphBuild> {
14626    build_traversal_graph_source_with_options(root, path_hint, scope, false)
14627}
14628
14629/// Bounded acquire deadline for the graph-db cross-process write lock. flock
14630/// releases automatically if the holder dies, so a live writer is the only thing
14631/// that can hold this; the bound serializes brief contention and fails closed
14632/// with a clear diagnostic on a wedged holder rather than hanging forever.
14633const GRAPH_DB_WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(15);
14634const GRAPH_DB_WRITE_LOCK_POLL: Duration = Duration::from_millis(50);
14635
14636/// RAII guard for the graph-db advisory write lock; unlocks on drop.
14637pub(crate) struct GraphDbWriteLock {
14638    file: std::fs::File,
14639}
14640
14641impl Drop for GraphDbWriteLock {
14642    fn drop(&mut self) {
14643        let _ = fs4::fs_std::FileExt::unlock(&self.file);
14644    }
14645}
14646
14647pub(crate) fn graph_db_write_lock_path(graph_db: &Path) -> PathBuf {
14648    let stem = graph_db
14649        .file_stem()
14650        .and_then(|stem| stem.to_str())
14651        .unwrap_or("graph");
14652    graph_db.with_file_name(format!("{stem}.write.lock"))
14653}
14654
14655/// Acquire the cross-process advisory write lock guarding graph-db refresh/write
14656/// and snapshot-import against concurrent agent processes. SQLite's busy_timeout
14657/// alone left the write transaction and the snapshot-import rename window racing
14658/// concurrent writers (#gdbwritelock): a refresh could create `-wal`/`-shm`
14659/// sidecars mid-rename and corrupt the freshly imported db, and parallel
14660/// refreshes spuriously failed `database is locked`. This serializes them.
14661pub(crate) fn acquire_graph_db_write_lock(graph_db: &Path) -> Result<GraphDbWriteLock> {
14662    acquire_graph_db_write_lock_with_timeout(graph_db, GRAPH_DB_WRITE_LOCK_TIMEOUT)
14663}
14664
14665pub(crate) fn acquire_graph_db_write_lock_with_timeout(
14666    graph_db: &Path,
14667    timeout: Duration,
14668) -> Result<GraphDbWriteLock> {
14669    use fs4::fs_std::FileExt;
14670
14671    let lock_path = graph_db_write_lock_path(graph_db);
14672    if let Some(parent) = lock_path.parent() {
14673        fs::create_dir_all(parent)
14674            .with_context(|| format!("creating graph-db lock dir: {}", parent.display()))?;
14675    }
14676    let file = std::fs::OpenOptions::new()
14677        .read(true)
14678        .write(true)
14679        .create(true)
14680        .truncate(false)
14681        .open(&lock_path)
14682        .with_context(|| format!("opening graph-db write lock {}", lock_path.display()))?;
14683
14684    let deadline = Instant::now() + timeout;
14685    loop {
14686        match file.try_lock_exclusive() {
14687            Ok(true) => return Ok(GraphDbWriteLock { file }),
14688            Ok(false) => {
14689                if Instant::now() >= deadline {
14690                    bail!(
14691                        "another tsift graph-db writer is active for {} (lock: {}); \
14692                         a concurrent graph-db refresh or snapshot-import is in progress, \
14693                         wait for it to finish before retrying",
14694                        graph_db.display(),
14695                        lock_path.display()
14696                    );
14697                }
14698                std::thread::sleep(GRAPH_DB_WRITE_LOCK_POLL);
14699            }
14700            Err(err) => {
14701                return Err(err).with_context(|| {
14702                    format!("locking graph-db write lock {}", lock_path.display())
14703                });
14704            }
14705        }
14706    }
14707}
14708
14709pub(crate) fn write_traversal_graph_store_with_options(
14710    root: &Path,
14711    path_hint: &Path,
14712    scope: Option<&str>,
14713    session_only: bool,
14714) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14715    let source_graph =
14716        build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14717    let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14718    let graph_db = graph_substrate_db_path(root, scope);
14719    // Serialize the write against concurrent refresh/snapshot-import (#gdbwritelock).
14720    let _write_lock = acquire_graph_db_write_lock(&graph_db)?;
14721    let mut store = SqliteGraphStore::open(&graph_db)?;
14722    let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14723        .ok()
14724        .flatten()
14725        .or_else(|| graph_projection_content_hash(&projection));
14726    let refresh = store.replace_projection_with_version(
14727        scope.unwrap_or("root"),
14728        &projection,
14729        Some(GRAPH_PROJECTION_VERSION),
14730        source_watermark,
14731    )?;
14732    Ok((source_graph, refresh))
14733}
14734
14735pub(crate) fn write_traversal_graph_store(
14736    root: &Path,
14737    path_hint: &Path,
14738    scope: Option<&str>,
14739) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14740    write_traversal_graph_store_with_options(root, path_hint, scope, false)
14741}
14742
14743fn refresh_traversal_graph_store_with_options(
14744    root: &Path,
14745    path_hint: &Path,
14746    scope: Option<&str>,
14747    session_only: bool,
14748) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14749    let (source_graph, refresh) =
14750        write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14751    let graph_db = graph_substrate_db_path(root, scope);
14752    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14753    let mut graph = traversal_graph_from_store(root, &store)?;
14754    graph.warnings = source_graph.warnings;
14755    Ok((graph, refresh))
14756}
14757
14758fn refresh_traversal_graph_store(
14759    root: &Path,
14760    path_hint: &Path,
14761    scope: Option<&str>,
14762) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14763    refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14764}
14765
14766pub(crate) fn build_traversal_graph(
14767    root: &Path,
14768    path_hint: &Path,
14769    scope: Option<&str>,
14770) -> Result<TraversalGraphBuild> {
14771    let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14772    Ok(graph)
14773}
14774
14775fn traversal_query_kind_priority(kind: &str) -> usize {
14776    match kind {
14777        "backlog" => 0,
14778        "job_packet" => 1,
14779        "worker_result" => 2,
14780        "symbol" => 3,
14781        "ast_span" => 4,
14782        "file" => 5,
14783        "route" => 6,
14784        "cargo_package" => 7,
14785        "cargo_workspace" => 8,
14786        "session" => 9,
14787        "semantic_concept" => 10,
14788        "semantic_entity" => 11,
14789        _ => 12,
14790    }
14791}
14792
14793fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14794    let trimmed = query.trim();
14795    if trimmed.is_empty() {
14796        return None;
14797    }
14798    let kind_priority = traversal_query_kind_priority(&node.kind);
14799    if node.handle == trimmed {
14800        return Some((0, kind_priority, node.handle.clone()));
14801    }
14802    if node.path.as_deref() == Some(trimmed) {
14803        let path_priority = if node.kind == "file" {
14804            0
14805        } else {
14806            kind_priority.saturating_add(1)
14807        };
14808        return Some((1, path_priority, node.handle.clone()));
14809    }
14810    let normalized_backlog = trimmed.trim_start_matches('#');
14811    if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14812    {
14813        return Some((2, kind_priority, node.handle.clone()));
14814    }
14815    if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14816        return Some((3, kind_priority, node.handle.clone()));
14817    }
14818    None
14819}
14820
14821fn resolve_traversal_node<'a>(
14822    graph: &'a TraversalGraphBuild,
14823    query: &str,
14824) -> Option<&'a TraversalNode> {
14825    graph
14826        .nodes
14827        .values()
14828        .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14829        .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14830        .map(|(_, node)| node)
14831}
14832
14833fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14834    let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14835    for edge in edges {
14836        adj.entry(edge.from.clone())
14837            .or_default()
14838            .insert(edge.to.clone());
14839        adj.entry(edge.to.clone())
14840            .or_default()
14841            .insert(edge.from.clone());
14842    }
14843    adj.into_iter()
14844        .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14845        .collect()
14846}
14847
14848fn traversal_shortest_handles(
14849    edges: &[TraversalEdge],
14850    from: &str,
14851    to: &str,
14852) -> Option<Vec<String>> {
14853    if from == to {
14854        return Some(vec![from.to_string()]);
14855    }
14856    let adj = traversal_adjacency(edges);
14857    if !adj.contains_key(from) || !adj.contains_key(to) {
14858        return None;
14859    }
14860    let mut visited = BTreeSet::new();
14861    let mut queue = VecDeque::new();
14862    let mut parent = BTreeMap::<String, String>::new();
14863    visited.insert(from.to_string());
14864    queue.push_back(from.to_string());
14865    while let Some(current) = queue.pop_front() {
14866        if let Some(neighbors) = adj.get(&current) {
14867            for neighbor in neighbors {
14868                if visited.insert(neighbor.clone()) {
14869                    parent.insert(neighbor.clone(), current.clone());
14870                    if neighbor == to {
14871                        let mut path = vec![to.to_string()];
14872                        let mut cursor = to.to_string();
14873                        while let Some(prev) = parent.get(&cursor) {
14874                            path.push(prev.clone());
14875                            cursor = prev.clone();
14876                        }
14877                        path.reverse();
14878                        return Some(path);
14879                    }
14880                    queue.push_back(neighbor.clone());
14881                }
14882            }
14883        }
14884    }
14885    None
14886}
14887
14888fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14889    let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14890    for edge in edges {
14891        let neighbor = if edge.from == current {
14892            edge.to.as_str()
14893        } else if edge.to == current {
14894            edge.from.as_str()
14895        } else {
14896            continue;
14897        };
14898        let score = traversal_relation_score(edge, current);
14899        best_score_by_neighbor
14900            .entry(neighbor.to_string())
14901            .and_modify(|best| *best = (*best).max(score))
14902            .or_insert(score);
14903    }
14904    let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14905    ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14906        right_score
14907            .cmp(left_score)
14908            .then_with(|| left_handle.cmp(right_handle))
14909    });
14910    ranked.into_iter().map(|(handle, _)| handle).collect()
14911}
14912
14913fn traversal_neighborhood_handles(
14914    edges: &[TraversalEdge],
14915    origin: &str,
14916    depth: usize,
14917    limit: usize,
14918) -> BTreeSet<String> {
14919    let mut seen = BTreeSet::new();
14920    let mut queue = VecDeque::new();
14921    seen.insert(origin.to_string());
14922    queue.push_back((origin.to_string(), 0usize));
14923    while let Some((current, current_depth)) = queue.pop_front() {
14924        if current_depth >= depth {
14925            continue;
14926        }
14927        for neighbor in traversal_scored_neighbors(edges, &current) {
14928            if limit > 0 && seen.len() >= limit {
14929                return seen;
14930            }
14931            if seen.insert(neighbor.clone()) {
14932                queue.push_back((neighbor, current_depth + 1));
14933            }
14934        }
14935    }
14936    seen
14937}
14938
14939fn traversal_edges_between(
14940    handles: &BTreeSet<String>,
14941    edges: &[TraversalEdge],
14942) -> Vec<TraversalEdge> {
14943    edges
14944        .iter()
14945        .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14946        .cloned()
14947        .collect()
14948}
14949
14950fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14951    let mut result = Vec::new();
14952    for pair in path.windows(2) {
14953        if let Some(edge) = edges.iter().find(|edge| {
14954            (edge.from == pair[0] && edge.to == pair[1])
14955                || (edge.from == pair[1] && edge.to == pair[0])
14956        }) {
14957            result.push(edge.clone());
14958        }
14959    }
14960    result
14961}
14962
14963fn sorted_traversal_nodes<'a>(
14964    nodes: impl IntoIterator<Item = &'a TraversalNode>,
14965) -> Vec<TraversalNode> {
14966    let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14967    nodes.sort_by(|left, right| {
14968        left.kind
14969            .cmp(&right.kind)
14970            .then_with(|| left.label.cmp(&right.label))
14971            .then_with(|| left.path.cmp(&right.path))
14972            .then_with(|| left.handle.cmp(&right.handle))
14973    });
14974    nodes
14975}
14976
14977fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14978    let base = match edge.relation.as_str() {
14979        "mentions" => 100,
14980        "contains" => 80,
14981        "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14982        "contains_embedded_symbol" | "embedded_in_fence" => 77,
14983        "contains_markdown_block"
14984        | "contains_embedded_code"
14985        | "enclosing_module"
14986        | "enclosing_section" => 76,
14987        "calls" => {
14988            if edge.from == origin {
14989                70
14990            } else {
14991                65
14992            }
14993        }
14994        "handled_by" | "handles_route" => 68,
14995        "defines_route" => 62,
14996        "imports" => 62,
14997        "previous_sibling" | "next_sibling" => 54,
14998        "mentions_concept" | "mentions_entity" => 66,
14999        "semantic_relation" => 64,
15000        "tagged_concept" | "related_concept" => 58,
15001        "defines" => {
15002            if edge.from == origin {
15003                60
15004            } else {
15005                55
15006            }
15007        }
15008        _ => 10,
15009    };
15010    base + edge.weight
15011}
15012
15013fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
15014    match edge.relation.as_str() {
15015        "mentions" => "matched from backlog/session text".to_string(),
15016        "contains" => "contained in the selected session artifact".to_string(),
15017        "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
15018        "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
15019        "parent" => "parent AST span".to_string(),
15020        "child" => "child AST span".to_string(),
15021        "previous_sibling" => "previous AST sibling".to_string(),
15022        "next_sibling" => "next AST sibling".to_string(),
15023        "contains_markdown_block" => "Markdown section block".to_string(),
15024        "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
15025        "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
15026        "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
15027        "enclosing_module" => "nearest enclosing module".to_string(),
15028        "enclosing_section" => "nearest enclosing Markdown section".to_string(),
15029        "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
15030        "defines" => "file that defines the selected symbol".to_string(),
15031        "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
15032        "defines_route" => "file that declares the selected route".to_string(),
15033        "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
15034        "handled_by" => "route handled by the selected symbol".to_string(),
15035        "handles_route" => "route handled by the selected AST span".to_string(),
15036        "imports" => "import dependency from the selected package".to_string(),
15037        "mentions_concept" => "cached summary concept for the selected source".to_string(),
15038        "mentions_entity" => "cached summary entity for the selected source".to_string(),
15039        "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
15040        "tagged_concept" => "concept label attached to the selected entity".to_string(),
15041        "related_concept" => "co-occurring cached summary concept".to_string(),
15042        "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
15043        "calls" => "caller of the selected symbol".to_string(),
15044        other => format!("connected by {other}"),
15045    }
15046}
15047
15048fn traversal_recommendations(
15049    graph: &TraversalGraphBuild,
15050    origin: Option<&str>,
15051    shortest_path: Option<&[String]>,
15052    limit: usize,
15053) -> Vec<TraversalRecommendation> {
15054    let Some(origin) = origin else {
15055        return Vec::new();
15056    };
15057    let mut recommendations = Vec::new();
15058    let mut seen = BTreeSet::new();
15059
15060    if let Some(path) = shortest_path
15061        && path.len() > 1
15062        && path.first().is_some_and(|handle| handle == origin)
15063        && let Some(next) = graph.nodes.get(&path[1])
15064    {
15065        seen.insert(next.handle.clone());
15066        recommendations.push(TraversalRecommendation {
15067            handle: next.handle.clone(),
15068            kind: next.kind.clone(),
15069            label: next.label.clone(),
15070            reason: "next hop on shortest path".to_string(),
15071            score: 1_000,
15072            expand: next.expand.clone(),
15073        });
15074    }
15075
15076    let mut candidates = graph
15077        .edges
15078        .iter()
15079        .filter_map(|edge| {
15080            let neighbor = if edge.from == origin {
15081                edge.to.as_str()
15082            } else if edge.to == origin {
15083                edge.from.as_str()
15084            } else {
15085                return None;
15086            };
15087            let node = graph.nodes.get(neighbor)?;
15088            Some((traversal_relation_score(edge, origin), edge, node))
15089        })
15090        .collect::<Vec<_>>();
15091    candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
15092        right_score
15093            .cmp(left_score)
15094            .then_with(|| left.kind.cmp(&right.kind))
15095            .then_with(|| left.label.cmp(&right.label))
15096            .then_with(|| left.handle.cmp(&right.handle))
15097    });
15098
15099    let max = if limit == 0 { usize::MAX } else { limit };
15100    for (score, edge, node) in candidates {
15101        if recommendations.len() >= max {
15102            break;
15103        }
15104        if seen.insert(node.handle.clone()) {
15105            recommendations.push(TraversalRecommendation {
15106                handle: node.handle.clone(),
15107                kind: node.kind.clone(),
15108                label: node.label.clone(),
15109                reason: traversal_recommendation_reason(edge, origin),
15110                score,
15111                expand: node.expand.clone(),
15112            });
15113        }
15114    }
15115
15116    recommendations
15117}
15118
15119fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
15120    let scale = nodes.saturating_add(edges);
15121    if scale <= 80 {
15122        ExplorationBudget {
15123            project_size: "small".to_string(),
15124            max_source_windows: 8,
15125            lines_per_window: 96,
15126            relationship_limit: 40,
15127        }
15128    } else if scale <= 800 {
15129        ExplorationBudget {
15130            project_size: "medium".to_string(),
15131            max_source_windows: 6,
15132            lines_per_window: 80,
15133            relationship_limit: 32,
15134        }
15135    } else {
15136        ExplorationBudget {
15137            project_size: "large".to_string(),
15138            max_source_windows: 4,
15139            lines_per_window: 64,
15140            relationship_limit: 24,
15141        }
15142    }
15143}
15144
15145fn exploration_node_label(node: &TraversalNode) -> String {
15146    format!("{}:{}", node.kind, node.label)
15147}
15148
15149fn exploration_source_window_for_node(
15150    root: &Path,
15151    node: &TraversalNode,
15152    budget: &ExplorationBudget,
15153) -> Option<ExplorationSourceWindow> {
15154    let file = node.path.as_ref()?;
15155    let anchor = node
15156        .line
15157        .and_then(|line| usize::try_from(line).ok())
15158        .and_then(|line| line.checked_add(1))
15159        .unwrap_or(1);
15160    let context_before = budget.lines_per_window / 3;
15161    let start = anchor.saturating_sub(context_before).max(1);
15162    let end = start
15163        .saturating_add(budget.lines_per_window)
15164        .saturating_sub(1);
15165    let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
15166    Some(ExplorationSourceWindow {
15167        handle,
15168        file: file.clone(),
15169        start,
15170        end,
15171        reason: format!("cluster around {}", exploration_node_label(node)),
15172        expand: source_read_command(root, file, start, budget.lines_per_window),
15173    })
15174}
15175
15176fn build_exploration_packet(
15177    root: &Path,
15178    totals: &TraversalTotals,
15179    selected_nodes: &[TraversalNode],
15180    selected_edges: &[TraversalEdge],
15181) -> ExplorationPacket {
15182    let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
15183    let node_by_handle = selected_nodes
15184        .iter()
15185        .map(|node| (node.handle.as_str(), node))
15186        .collect::<BTreeMap<_, _>>();
15187    let relationship_map = selected_edges
15188        .iter()
15189        .take(budget.relationship_limit)
15190        .filter_map(|edge| {
15191            let from = node_by_handle.get(edge.from.as_str())?;
15192            let to = node_by_handle.get(edge.to.as_str())?;
15193            Some(ExplorationRelation {
15194                from: exploration_node_label(from),
15195                relation: edge.relation.clone(),
15196                to: exploration_node_label(to),
15197                label: edge.label.clone(),
15198            })
15199        })
15200        .collect::<Vec<_>>();
15201
15202    let mut seen_windows = BTreeSet::new();
15203    let mut source_windows = Vec::new();
15204    for node in selected_nodes {
15205        if source_windows.len() >= budget.max_source_windows {
15206            break;
15207        }
15208        let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
15209            continue;
15210        };
15211        let key = (window.file.clone(), window.start, window.end);
15212        if seen_windows.insert(key) {
15213            source_windows.push(window);
15214        }
15215    }
15216
15217    ExplorationPacket {
15218        budget,
15219        relationship_map,
15220        source_windows,
15221        worker_context: Vec::new(),
15222        no_reread_guidance:
15223            "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
15224                .to_string(),
15225    }
15226}
15227
15228pub(crate) fn traversal_report(
15229    root: &Path,
15230    scope: Option<&str>,
15231    graph: TraversalGraphBuild,
15232    query: Option<&str>,
15233    target: Option<&str>,
15234    depth: usize,
15235    limit: usize,
15236) -> Result<TraversalReport> {
15237    let totals = TraversalTotals {
15238        nodes: graph.nodes.len(),
15239        edges: graph.edges.len(),
15240    };
15241    let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
15242    let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
15243    if let Some(query) = query
15244        && origin_node.is_none()
15245    {
15246        bail!("traversal node not found: {}", query);
15247    }
15248    if let Some(target) = target
15249        && target_node.is_none()
15250    {
15251        bail!("traversal target not found: {}", target);
15252    }
15253
15254    let (mode, selected_nodes, selected_edges, shortest_path) =
15255        if let (Some(origin), Some(target)) = (origin_node, target_node) {
15256            if let Some(handles) =
15257                traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
15258            {
15259                let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
15260                let nodes = handles
15261                    .iter()
15262                    .filter_map(|handle| graph.nodes.get(handle).cloned())
15263                    .collect::<Vec<_>>();
15264                let edges = traversal_path_edges(&handles, &graph.edges);
15265                let path = TraversalPathReport {
15266                    from: origin.clone(),
15267                    to: target.clone(),
15268                    hops: handles.len().saturating_sub(1),
15269                    nodes: nodes.clone(),
15270                    edges: edges.clone(),
15271                };
15272                (
15273                    "path".to_string(),
15274                    nodes,
15275                    traversal_edges_between(&handle_set, &graph.edges),
15276                    Some(path),
15277                )
15278            } else {
15279                (
15280                    "path".to_string(),
15281                    vec![origin.clone(), target.clone()],
15282                    Vec::new(),
15283                    None,
15284                )
15285            }
15286        } else if let Some(origin) = origin_node {
15287            let handles =
15288                traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
15289            let nodes =
15290                sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
15291            let edges = traversal_edges_between(&handles, &graph.edges);
15292            ("neighborhood".to_string(), nodes, edges, None)
15293        } else {
15294            let mut nodes = sorted_traversal_nodes(graph.nodes.values());
15295            let truncated_nodes = limit > 0 && nodes.len() > limit;
15296            if truncated_nodes {
15297                nodes.truncate(limit);
15298            }
15299            let handles = nodes
15300                .iter()
15301                .map(|node| node.handle.clone())
15302                .collect::<BTreeSet<_>>();
15303            let mut edges = traversal_edges_between(&handles, &graph.edges);
15304            let truncated_edges = limit > 0 && edges.len() > limit;
15305            if truncated_edges {
15306                edges.truncate(limit);
15307            }
15308            ("export".to_string(), nodes, edges, None)
15309        };
15310
15311    let shortest_handles = shortest_path.as_ref().map(|path| {
15312        path.nodes
15313            .iter()
15314            .map(|node| node.handle.clone())
15315            .collect::<Vec<_>>()
15316    });
15317    let recommendations = traversal_recommendations(
15318        &graph,
15319        origin_node.map(|node| node.handle.as_str()),
15320        shortest_handles.as_deref(),
15321        if limit == 0 { 10 } else { limit.min(10) },
15322    );
15323    let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
15324    let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
15325
15326    Ok(TraversalReport {
15327        root: root.to_string_lossy().to_string(),
15328        scope: scope.map(str::to_string),
15329        mode,
15330        totals,
15331        query: query.map(str::to_string),
15332        target: target.map(str::to_string),
15333        nodes: selected_nodes,
15334        edges: selected_edges,
15335        shortest_path,
15336        recommendations,
15337        exploration,
15338        truncated,
15339        warnings: graph.warnings,
15340    })
15341}
15342
15343fn html_escape(input: &str) -> String {
15344    input
15345        .replace('&', "&amp;")
15346        .replace('<', "&lt;")
15347        .replace('>', "&gt;")
15348        .replace('"', "&quot;")
15349        .replace('\'', "&#39;")
15350}
15351
15352pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
15353    let json = serde_json::to_string(report)?.replace("</", "<\\/");
15354    let mut html = String::new();
15355    html.push_str(
15356        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
15357    );
15358    html.push_str(
15359        r#"<style>
15360:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
15361@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
15362*{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}}
15363</style>"#,
15364    );
15365    html.push_str("</head><body>");
15366    html.push_str("<div class=\"page\">");
15367    html.push_str(&format!(
15368        "<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>",
15369        html_escape(&report.mode),
15370        report.nodes.len(),
15371        report.totals.nodes,
15372        report.edges.len(),
15373        report.totals.edges
15374    ));
15375    html.push_str(
15376        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>"#,
15377    );
15378    html.push_str("<script id=\"graph-data\" type=\"application/json\">");
15379    html.push_str(&json);
15380    html.push_str(
15381        r##"</script><script>
15382const report = JSON.parse(document.getElementById("graph-data").textContent);
15383const svg = document.getElementById("graph-canvas");
15384const list = document.getElementById("node-list");
15385const selected = document.getElementById("selected");
15386const filter = document.getElementById("filter");
15387const legend = document.getElementById("legend");
15388const nodes = report.nodes.map((node, index) => ({...node, index}));
15389const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
15390const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
15391const colorByKind = new Map([
15392  ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
15393  ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
15394  ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
15395  ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
15396]);
15397function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
15398function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
15399function text(value){ return value == null ? "" : String(value); }
15400function matches(node, query){
15401  if (!query) return true;
15402  const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
15403  return haystack.includes(query);
15404}
15405function layout(){
15406  const rect = svg.getBoundingClientRect();
15407  const width = rect.width || 900;
15408  const height = rect.height || 650;
15409  const cx = width / 2;
15410  const cy = height / 2;
15411  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15412  const counts = new Map();
15413  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
15414  const offsets = new Map();
15415  for (const node of nodes) {
15416    const group = kinds.indexOf(node.kind);
15417    const index = offsets.get(node.kind) || 0;
15418    offsets.set(node.kind, index + 1);
15419    const groupCount = counts.get(node.kind) || 1;
15420    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
15421    const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
15422    node.x = cx + Math.cos(angle) * ring;
15423    node.y = cy + Math.sin(angle) * ring;
15424  }
15425}
15426function draw(){
15427  const query = filter.value.trim().toLowerCase();
15428  const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
15429  svg.innerHTML = "";
15430  for (const edge of edges) {
15431    if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
15432    const from = nodeByHandle.get(edge.from);
15433    const to = nodeByHandle.get(edge.to);
15434    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
15435    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
15436    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
15437    line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
15438    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
15439    svg.appendChild(line);
15440  }
15441  for (const node of nodes) {
15442    if (!visible.has(node.handle)) continue;
15443    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
15444    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
15445    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
15446    circle.setAttribute("fill", color(node.kind));
15447    circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
15448    circle.addEventListener("click", () => selectNode(node));
15449    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
15450    svg.appendChild(circle);
15451    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
15452    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
15453    label.setAttribute("class", "node-label");
15454    label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
15455    svg.appendChild(label);
15456  }
15457  renderList(query);
15458}
15459function renderLegend(){
15460  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15461  legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">&#9679;</b> ${kind}</span>`).join("");
15462}
15463function renderList(query){
15464  const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
15465  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("");
15466  for (const row of list.querySelectorAll(".row")) {
15467    row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
15468  }
15469}
15470function selectNode(node){
15471  const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
15472  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>`;
15473}
15474function escapeHtml(value){
15475  return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));
15476}
15477filter.addEventListener("input", draw);
15478window.addEventListener("resize", () => { layout(); draw(); });
15479renderLegend();
15480layout();
15481draw();
15482if (nodes.length) selectNode(nodes[0]);
15483</script></div></body></html>"##,
15484    );
15485    Ok(html)
15486}
15487
15488fn semantic_related_report_from_store(
15489    root: &Path,
15490    scope: Option<&str>,
15491    query: &str,
15492    limit: usize,
15493    kind: SemanticRelatedKind,
15494    store: &impl GraphStore,
15495) -> Result<SemanticRelatedReport> {
15496    if query.trim().is_empty() {
15497        bail!("semantic query cannot be empty");
15498    }
15499
15500    let query_embedding = semantic_embedding(query);
15501    let node_kinds: &[&str] = match kind {
15502        SemanticRelatedKind::Concept => &["semantic_concept"],
15503        SemanticRelatedKind::Entity => &["semantic_entity"],
15504        SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
15505    };
15506
15507    let items = store
15508        .semantic_top_candidates(&query_embedding, node_kinds, limit)?
15509        .into_iter()
15510        .map(|candidate| {
15511            let node = candidate.node;
15512            SemanticRelatedItem {
15513                handle: node
15514                    .properties
15515                    .get("handle")
15516                    .cloned()
15517                    .unwrap_or_else(|| node.id.clone()),
15518                kind: node.kind,
15519                label: node.label,
15520                score: candidate.score,
15521                file_path: node
15522                    .properties
15523                    .get("source_file")
15524                    .or_else(|| node.properties.get("path"))
15525                    .cloned(),
15526                source_symbol: node.properties.get("source_symbol").cloned(),
15527                detail: node
15528                    .properties
15529                    .get("description")
15530                    .or_else(|| node.properties.get("detail"))
15531                    .cloned(),
15532                expand: node
15533                    .properties
15534                    .get("expand")
15535                    .cloned()
15536                    .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
15537            }
15538        })
15539        .collect::<Vec<_>>();
15540
15541    let mut warnings = Vec::new();
15542    if items.is_empty() {
15543        warnings.push(
15544            "no semantic graph rows found; run `tsift summarize --extract <path>` first"
15545                .to_string(),
15546        );
15547    }
15548
15549    Ok(SemanticRelatedReport {
15550        root: root.to_string_lossy().to_string(),
15551        scope: scope.map(str::to_string),
15552        query: query.to_string(),
15553        embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
15554        count: items.len(),
15555        items,
15556        warnings,
15557    })
15558}
15559
15560fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
15561    Ok(store.nodes_by_kind("semantic_concept")?.len()
15562        + store.nodes_by_kind("semantic_entity")?.len())
15563}
15564
15565fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
15566    if limit == 0 {
15567        return 0;
15568    }
15569    limit.saturating_mul(4).clamp(
15570        GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
15571        GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
15572    )
15573}
15574
15575fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
15576    if limit == 0 {
15577        return usize::MAX;
15578    }
15579    limit.saturating_mul(3).max(limit).max(seed_count)
15580}
15581
15582fn graph_db_semantic_seeded_neighborhood(
15583    store: &impl GraphStore,
15584    seed_ids: &[String],
15585    depth: usize,
15586    limit: usize,
15587) -> Result<GraphDbSemanticSeededSubgraph> {
15588    let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
15589    let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
15590    let mut diagnostics = vec![
15591        "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
15592        "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(),
15593        format!(
15594            "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
15595            if edge_scan_cap == 0 {
15596                "unbounded".to_string()
15597            } else {
15598                edge_scan_cap.to_string()
15599            },
15600            if node_discovery_cap == usize::MAX {
15601                "unbounded".to_string()
15602            } else {
15603                node_discovery_cap.to_string()
15604            }
15605        ),
15606    ];
15607
15608    let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
15609        .with_edge_scan_cap(edge_scan_cap)
15610        .with_node_discovery_cap(node_discovery_cap);
15611    let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
15612
15613    for seed_id in &result.missing_seed_ids {
15614        diagnostics.push(format!(
15615            "semantic seed {seed_id} was not present in the graph store"
15616        ));
15617    }
15618
15619    if result.skipped_by_edge_cap > 0 {
15620        diagnostics.push(format!(
15621            "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
15622            result.skipped_by_edge_cap
15623        ));
15624    }
15625    if result.skipped_by_node_cap > 0 {
15626        diagnostics.push(format!(
15627            "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
15628            result.skipped_by_node_cap
15629        ));
15630    }
15631
15632    if result.truncated {
15633        diagnostics.push(format!(
15634            "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
15635            result.total_discovered
15636        ));
15637    }
15638
15639    Ok(GraphDbSemanticSeededSubgraph {
15640        nodes: result.nodes,
15641        edges: result.edges,
15642        truncated: result.truncated,
15643        diagnostics,
15644    })
15645}
15646
15647#[allow(clippy::too_many_arguments)]
15648fn cmd_semantic_related(
15649    query: &str,
15650    path: &Path,
15651    scope: Option<&str>,
15652    limit: usize,
15653    kind: SemanticRelatedKind,
15654    json_output: bool,
15655    compact: bool,
15656    pretty: bool,
15657    terse: bool,
15658    schema: bool,
15659    profile: Option<String>,
15660) -> Result<()> {
15661    let root = lint::resolve_project_root_or_canonical_path(path)?;
15662    write_traversal_graph_store(&root, path, scope)?;
15663    let graph_db = graph_substrate_db_path(&root, scope);
15664    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15665    let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15666    if let Some(recovery) = store.read_only_recovery() {
15667        report
15668            .warnings
15669            .push(graph_db_read_recovery_diagnostic(recovery));
15670    }
15671    if let Some(note) =
15672        profile_preference_note(profile.as_deref(), tsift_local_model::ModelRole::Embed)
15673    {
15674        report.warnings.push(note);
15675    }
15676
15677    if json_output {
15678        println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15679    } else if compact {
15680        for item in &report.items {
15681            println!(
15682                "{:.3}\t{}\t{}\t{}",
15683                item.score, item.kind, item.label, item.handle
15684            );
15685        }
15686        for warning in &report.warnings {
15687            eprintln!("warning: {warning}");
15688        }
15689    } else {
15690        println!(
15691            "Related semantic graph rows for {:?} ({})",
15692            report.query, report.embedding_model
15693        );
15694        for item in &report.items {
15695            println!(
15696                "  {:.3} [{}] {} ({})",
15697                item.score, item.kind, item.label, item.handle
15698            );
15699            if let Some(detail) = &item.detail {
15700                println!("      {}", detail);
15701            }
15702            if let Some(file_path) = &item.file_path {
15703                println!("      file: {}", file_path);
15704            }
15705            println!("      expand: {}", item.expand);
15706        }
15707        for warning in &report.warnings {
15708            eprintln!("warning: {warning}");
15709        }
15710    }
15711
15712    Ok(())
15713}
15714
15715/// Resolve a `--profile` CLI value into a one-line note for the response
15716/// envelope. Records the caller's intent and the resolved profile id, so
15717/// downstream readers see what would have been used even while the actual
15718/// provider seam still falls back to the hash profile (#gctrl2).
15719fn profile_preference_note(
15720    profile: Option<&str>,
15721    role: tsift_local_model::ModelRole,
15722) -> Option<String> {
15723    let preference = tsift_local_model::ProfilePreference::from_cli(profile);
15724    if matches!(preference, tsift_local_model::ProfilePreference::Auto) {
15725        return None;
15726    }
15727    let probe = tsift_local_model::probe_nvidia_smi();
15728    let resolution = tsift_local_model::resolve_profile_preference(&preference, role, &probe);
15729    Some(format!(
15730        "profile preference {} -> {} ({})",
15731        preference.describe(),
15732        resolution.profile.id,
15733        resolution.reason
15734    ))
15735}
15736
15737#[derive(Serialize)]
15738struct SourceLinePreview {
15739    line: usize,
15740    text: String,
15741}
15742
15743#[derive(Serialize)]
15744pub(crate) struct SourceRangePreview {
15745    start: usize,
15746    end: usize,
15747    total_lines: usize,
15748    truncated_before: bool,
15749    truncated_after: bool,
15750}
15751
15752#[derive(Serialize)]
15753struct SourceExpandCommands {
15754    #[serde(skip_serializing_if = "Option::is_none")]
15755    before: Option<String>,
15756    #[serde(skip_serializing_if = "Option::is_none")]
15757    after: Option<String>,
15758    #[serde(skip_serializing_if = "Option::is_none")]
15759    body: Option<String>,
15760    file: String,
15761    #[serde(skip_serializing_if = "Option::is_none")]
15762    markdown_ast: Option<String>,
15763}
15764
15765#[derive(Serialize)]
15766struct SourceSymbolRef {
15767    handle: String,
15768    name: String,
15769    kind: String,
15770    language: String,
15771    file: String,
15772    line: usize,
15773    #[serde(skip_serializing_if = "Option::is_none")]
15774    end_line: Option<usize>,
15775    #[serde(skip_serializing_if = "Option::is_none")]
15776    signature: Option<String>,
15777    #[serde(skip_serializing_if = "Option::is_none")]
15778    span: Option<AstSpanPreview>,
15779    expand: String,
15780}
15781
15782#[derive(Serialize)]
15783struct SourceSummaryRef {
15784    handle: String,
15785    symbol_name: String,
15786    file_path: String,
15787    summary: String,
15788    expand: String,
15789}
15790
15791#[derive(Serialize)]
15792struct SourceReadReport {
15793    handle: String,
15794    root: String,
15795    file: String,
15796    range: SourceRangePreview,
15797    preview: Vec<SourceLinePreview>,
15798    symbols: Vec<SourceSymbolRef>,
15799    summaries: Vec<SourceSummaryRef>,
15800    #[serde(skip_serializing_if = "Option::is_none")]
15801    markdown: Option<SourceReadMarkdownProjection>,
15802    expand: SourceExpandCommands,
15803    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15804    warnings: Vec<String>,
15805}
15806
15807#[derive(Serialize)]
15808struct SourceReadAstExpandCommands {
15809    window: String,
15810    file_window: String,
15811    #[serde(skip_serializing_if = "Option::is_none")]
15812    markdown_ast: Option<String>,
15813}
15814
15815#[derive(Serialize)]
15816struct SourceReadAstReport {
15817    handle: String,
15818    root: String,
15819    file: String,
15820    range: SourceRangePreview,
15821    symbols: Vec<SourceSymbolRef>,
15822    summaries: Vec<SourceSummaryRef>,
15823    #[serde(skip_serializing_if = "Option::is_none")]
15824    markdown: Option<SourceReadMarkdownProjection>,
15825    expand: SourceReadAstExpandCommands,
15826    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15827    warnings: Vec<String>,
15828}
15829
15830#[derive(Serialize)]
15831struct SymbolReadTarget {
15832    handle: String,
15833    name: String,
15834    kind: String,
15835    language: String,
15836    file: String,
15837    line: usize,
15838    #[serde(skip_serializing_if = "Option::is_none")]
15839    end_line: Option<usize>,
15840    #[serde(skip_serializing_if = "Option::is_none")]
15841    signature: Option<String>,
15842    #[serde(skip_serializing_if = "Option::is_none")]
15843    parent_module: Option<String>,
15844    #[serde(skip_serializing_if = "Option::is_none")]
15845    visibility: Option<String>,
15846    #[serde(skip_serializing_if = "Option::is_none")]
15847    span: Option<AstSpanPreview>,
15848}
15849
15850#[derive(Serialize)]
15851struct SymbolReadExpandCommands {
15852    source_window: String,
15853    #[serde(skip_serializing_if = "Option::is_none")]
15854    body: Option<String>,
15855    file: String,
15856    explain: String,
15857    callers: String,
15858    callees: String,
15859    #[serde(skip_serializing_if = "Option::is_none")]
15860    markdown_ast: Option<String>,
15861}
15862
15863#[derive(Serialize)]
15864struct SymbolReadReport {
15865    handle: String,
15866    root: String,
15867    query: String,
15868    symbol: SymbolReadTarget,
15869    range: SourceRangePreview,
15870    body: Vec<SourceLinePreview>,
15871    child_symbols: Vec<SourceSymbolRef>,
15872    summaries: Vec<SourceSummaryRef>,
15873    expand: SymbolReadExpandCommands,
15874    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15875    warnings: Vec<String>,
15876}
15877
15878#[derive(Clone)]
15879pub(crate) struct MarkdownAstRawNode {
15880    handle: String,
15881    span_handle: String,
15882    name: String,
15883    kind: String,
15884    block_kind: String,
15885    node_kind: String,
15886    start_byte: usize,
15887    end_byte: usize,
15888    body_start_byte: Option<usize>,
15889    body_end_byte: Option<usize>,
15890}
15891
15892#[derive(Clone)]
15893pub(crate) struct MarkdownAstProjection {
15894    source_hash: String,
15895    nodes: Vec<MarkdownAstRawNode>,
15896    parse_duration_micros: u128,
15897    cache_hit: bool,
15898}
15899
15900#[derive(Clone)]
15901struct MarkdownAstCacheEntry {
15902    source_hash: String,
15903    nodes: Vec<MarkdownAstRawNode>,
15904    parse_duration_micros: u128,
15905}
15906
15907static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15908    OnceLock::new();
15909
15910#[derive(Serialize, Clone)]
15911struct MarkdownAstNodeMetadata {
15912    #[serde(skip_serializing_if = "Option::is_none")]
15913    heading_level: Option<usize>,
15914    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15915    section_path: Vec<String>,
15916    #[serde(skip_serializing_if = "Option::is_none")]
15917    section_handle: Option<String>,
15918    #[serde(skip_serializing_if = "Option::is_none")]
15919    list_depth: Option<usize>,
15920    #[serde(skip_serializing_if = "Option::is_none")]
15921    list_marker: Option<String>,
15922    #[serde(skip_serializing_if = "Option::is_none")]
15923    list_order: Option<usize>,
15924    #[serde(skip_serializing_if = "Option::is_none")]
15925    fence_language: Option<String>,
15926    #[serde(skip_serializing_if = "Option::is_none")]
15927    fence_marker: Option<String>,
15928    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15929    embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15930}
15931
15932#[derive(Serialize, Clone)]
15933struct MarkdownAstNodeExpand {
15934    source_window: String,
15935    source_body: String,
15936    symbol_read: String,
15937    edit_intents: String,
15938}
15939
15940#[derive(Serialize, Clone)]
15941struct MarkdownAstCacheReport {
15942    source_hash: String,
15943    cache_hit: bool,
15944    parse_duration_micros: u128,
15945    node_count: usize,
15946    section_count: usize,
15947    list_item_count: usize,
15948    code_block_count: usize,
15949}
15950
15951#[derive(Serialize, Clone)]
15952struct MarkdownAstPhaseTiming {
15953    name: String,
15954    duration_micros: u128,
15955    detail: String,
15956}
15957
15958#[derive(Serialize, Clone)]
15959struct MarkdownAstOutlineEntry {
15960    handle: String,
15961    span_handle: String,
15962    name: String,
15963    kind: String,
15964    block_kind: String,
15965    line: usize,
15966    end_line: usize,
15967    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15968    section_path: Vec<String>,
15969    child_count: usize,
15970    expand: String,
15971}
15972
15973#[derive(Serialize, Clone)]
15974struct MarkdownAstProjectionPreview {
15975    mode: String,
15976    total_nodes: usize,
15977    returned_nodes: usize,
15978    omitted_nodes: usize,
15979    selected_node: Option<String>,
15980    cache: MarkdownAstCacheReport,
15981    outline: Vec<MarkdownAstOutlineEntry>,
15982    phase_timings: Vec<MarkdownAstPhaseTiming>,
15983}
15984
15985#[derive(Serialize)]
15986struct SourceReadMarkdownProjection {
15987    handle: String,
15988    mode: String,
15989    total_nodes: usize,
15990    visible_nodes: usize,
15991    outline: Vec<MarkdownAstOutlineEntry>,
15992    expand: String,
15993}
15994
15995#[derive(Serialize, Clone)]
15996struct SourceByteRangePreview {
15997    start: usize,
15998    end: usize,
15999}
16000
16001#[derive(Serialize, Clone)]
16002struct MarkdownAstNode {
16003    handle: String,
16004    span_handle: String,
16005    name: String,
16006    kind: String,
16007    block_kind: String,
16008    node_kind: String,
16009    line: usize,
16010    end_line: usize,
16011    byte_span: SourceByteRangePreview,
16012    #[serde(skip_serializing_if = "Option::is_none")]
16013    body_byte_span: Option<SourceByteRangePreview>,
16014    parent_handle: Option<String>,
16015    #[serde(skip_serializing_if = "Vec::is_empty", default)]
16016    child_handles: Vec<String>,
16017    metadata: MarkdownAstNodeMetadata,
16018    expand: MarkdownAstNodeExpand,
16019}
16020
16021#[derive(Serialize)]
16022struct MarkdownAstExpandCommands {
16023    file: String,
16024    source_read: String,
16025    edit_intents: String,
16026}
16027
16028#[derive(Serialize)]
16029struct MarkdownAstReport {
16030    handle: String,
16031    root: String,
16032    file: String,
16033    range: SourceRangePreview,
16034    projection: MarkdownAstProjectionPreview,
16035    nodes: Vec<MarkdownAstNode>,
16036    expand: MarkdownAstExpandCommands,
16037    #[serde(skip_serializing_if = "Vec::is_empty", default)]
16038    warnings: Vec<String>,
16039}
16040
16041pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
16042    let candidate = if file.is_absolute() {
16043        file.to_path_buf()
16044    } else {
16045        root.join(file)
16046    };
16047    let canonical = candidate
16048        .canonicalize()
16049        .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
16050    if !canonical.is_file() {
16051        bail!("source file is not a regular file: {}", canonical.display());
16052    }
16053    let canonical_root = root
16054        .canonicalize()
16055        .with_context(|| format!("canonicalizing project root {}", root.display()))?;
16056    if !canonical.starts_with(&canonical_root) {
16057        bail!(
16058            "source file {} is outside project root {}",
16059            canonical.display(),
16060            canonical_root.display()
16061        );
16062    }
16063    Ok(canonical)
16064}
16065
16066pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
16067    source_read_window_command(root, file, start, lines)
16068}
16069
16070pub(crate) fn source_read_window_command(
16071    root: &Path,
16072    file: &str,
16073    start: usize,
16074    lines: usize,
16075) -> String {
16076    format!(
16077        "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
16078        shell_quote(file),
16079        shell_quote(&root.to_string_lossy()),
16080        start,
16081        lines
16082    )
16083}
16084
16085pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
16086    format!(
16087        "tsift --envelope source-read {} --path {} --budget normal",
16088        shell_quote(file),
16089        shell_quote(&root.to_string_lossy())
16090    )
16091}
16092
16093pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
16094    format!(
16095        "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
16096        shell_quote(symbol),
16097        shell_quote(&root.to_string_lossy()),
16098        shell_quote(file)
16099    )
16100}
16101
16102fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
16103    format!(
16104        "tsift --envelope explain {} --path {} --budget normal",
16105        shell_quote(symbol),
16106        shell_quote(&root.to_string_lossy())
16107    )
16108}
16109
16110fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
16111    format!(
16112        "tsift graph {} --path {} --{} --json",
16113        shell_quote(symbol),
16114        shell_quote(&root.to_string_lossy()),
16115        relation
16116    )
16117}
16118
16119fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
16120    format!(
16121        "tsift summarize {} --path {} --json",
16122        shell_quote(symbol),
16123        shell_quote(&root.to_string_lossy())
16124    )
16125}
16126
16127pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
16128    let mut command = format!(
16129        "tsift --envelope markdown-ast {} --path {} --budget normal",
16130        shell_quote(file),
16131        shell_quote(&root.to_string_lossy())
16132    );
16133    if let Some(node) = node {
16134        command.push_str(" --node ");
16135        command.push_str(&shell_quote(node));
16136    }
16137    command
16138}
16139
16140fn markdown_edit_intents_command(root: &Path) -> String {
16141    format!(
16142        "tsift --envelope edit-intents --path {} --budget normal",
16143        shell_quote(&root.to_string_lossy())
16144    )
16145}
16146
16147pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
16148    usize::try_from(symbol.line)
16149        .ok()
16150        .and_then(|line| line.checked_add(1))
16151        .unwrap_or(1)
16152}
16153
16154fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
16155    symbol
16156        .end_line
16157        .and_then(|line| usize::try_from(line).ok())
16158        .and_then(|line| line.checked_add(1))
16159}
16160
16161fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
16162    value.and_then(|byte| usize::try_from(byte).ok())
16163}
16164
16165fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
16166    let byte = byte.min(source.len());
16167    source[..byte]
16168        .iter()
16169        .filter(|value| **value == b'\n')
16170        .count()
16171        .saturating_add(1)
16172}
16173
16174fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
16175    source_line_for_byte(source, end_byte.saturating_sub(1))
16176}
16177
16178fn ast_span_handle(
16179    file: &str,
16180    name: &str,
16181    kind: &str,
16182    start_byte: usize,
16183    end_byte: usize,
16184) -> String {
16185    stable_handle(
16186        "span",
16187        &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
16188    )
16189}
16190
16191pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
16192    Some((
16193        symbol_span_byte(symbol.start_byte)?,
16194        symbol_span_byte(symbol.end_byte)?,
16195    ))
16196}
16197
16198pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
16199    Some((
16200        symbol_span_byte(symbol.start_byte)?,
16201        symbol_span_byte(symbol.end_byte)?,
16202    ))
16203}
16204
16205pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
16206    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16207    Some(ast_span_handle(
16208        &symbol.file,
16209        &symbol.name,
16210        &symbol.kind,
16211        start_byte,
16212        end_byte,
16213    ))
16214}
16215
16216fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
16217    left.file == right.file
16218        && left.name == right.name
16219        && left.kind == right.kind
16220        && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
16221}
16222
16223fn stored_symbol_parent_span_handle_in_file(
16224    symbol: &index::StoredSymbol,
16225    symbols: &[&index::StoredSymbol],
16226) -> Option<String> {
16227    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16228    symbols
16229        .iter()
16230        .copied()
16231        .filter(|candidate| {
16232            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16233                return false;
16234            }
16235            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16236            else {
16237                return false;
16238            };
16239            candidate_start <= start_byte && candidate_end >= end_byte
16240        })
16241        .min_by_key(|candidate| {
16242            stored_symbol_span_bounds(candidate)
16243                .map(|(start, end)| end.saturating_sub(start))
16244                .unwrap_or(usize::MAX)
16245        })
16246        .and_then(stored_symbol_span_handle)
16247}
16248
16249fn stored_symbol_child_span_handles_in_file(
16250    symbol: &index::StoredSymbol,
16251    symbols: &[&index::StoredSymbol],
16252    limit: usize,
16253) -> Vec<String> {
16254    let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
16255        return Vec::new();
16256    };
16257    symbols
16258        .iter()
16259        .copied()
16260        .filter(|candidate| {
16261            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16262                return false;
16263            }
16264            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16265            else {
16266                return false;
16267            };
16268            candidate_start >= start_byte && candidate_end <= end_byte
16269        })
16270        .take(limit)
16271        .filter_map(stored_symbol_span_handle)
16272        .collect()
16273}
16274
16275fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
16276    let start = start_byte.min(source.len());
16277    let line_end = source[start..]
16278        .iter()
16279        .position(|value| *value == b'\n')
16280        .map(|pos| start + pos)
16281        .unwrap_or(source.len());
16282    let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
16283    let marker = line.trim_start();
16284    let level = marker.chars().take_while(|ch| *ch == '#').count();
16285    (1..=6).contains(&level).then_some(level)
16286}
16287
16288fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
16289    let start = start_byte.min(source.len());
16290    let line_start = source[..start]
16291        .iter()
16292        .rposition(|value| *value == b'\n')
16293        .map(|pos| pos + 1)
16294        .unwrap_or(0);
16295    source[line_start..start]
16296        .iter()
16297        .map(|byte| match byte {
16298            b'\t' => 4,
16299            b' ' => 1,
16300            _ => 0,
16301        })
16302        .sum::<usize>()
16303        / 2
16304}
16305
16306fn markdown_enclosing_heading_symbols_in_file<'a>(
16307    file: &str,
16308    start_byte: usize,
16309    end_byte: usize,
16310    symbols: &[&'a index::StoredSymbol],
16311) -> Vec<&'a index::StoredSymbol> {
16312    let mut headings = symbols
16313        .iter()
16314        .copied()
16315        .filter(|candidate| candidate.file == file && candidate.kind == "heading")
16316        .filter(|candidate| {
16317            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16318            else {
16319                return false;
16320            };
16321            candidate_start <= start_byte && candidate_end >= end_byte
16322        })
16323        .collect::<Vec<_>>();
16324    headings.sort_by(|left, right| {
16325        stored_symbol_span_bounds(left)
16326            .map(|(start, _)| start)
16327            .unwrap_or(usize::MAX)
16328            .cmp(
16329                &stored_symbol_span_bounds(right)
16330                    .map(|(start, _)| start)
16331                    .unwrap_or(usize::MAX),
16332            )
16333            .then(left.name.cmp(&right.name))
16334    });
16335    headings
16336}
16337
16338fn markdown_stored_symbol_metadata_in_file(
16339    symbol: &index::StoredSymbol,
16340    source: &[u8],
16341    symbols: &[&index::StoredSymbol],
16342) -> Option<MarkdownSpanMetadata> {
16343    if symbol.language != "markdown" {
16344        return None;
16345    }
16346    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16347    let section_symbols =
16348        markdown_enclosing_heading_symbols_in_file(&symbol.file, start_byte, end_byte, symbols);
16349    let section_path = section_symbols
16350        .iter()
16351        .map(|heading| heading.name.clone())
16352        .collect::<Vec<_>>();
16353    let section_handle = section_symbols
16354        .last()
16355        .and_then(|heading| stored_symbol_span_handle(heading));
16356    let heading_level = (symbol.kind == "heading")
16357        .then(|| markdown_heading_level(source, start_byte))
16358        .flatten();
16359    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16360    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16361    let embedded_symbols = if symbol.kind == "code_block" {
16362        markdown_embedded_symbols(
16363            &symbol.file,
16364            source,
16365            symbol_span_byte(symbol.body_start_byte),
16366            symbol_span_byte(symbol.body_end_byte),
16367            fence_language.as_deref(),
16368        )
16369    } else {
16370        Vec::new()
16371    };
16372
16373    (heading_level.is_some()
16374        || !section_path.is_empty()
16375        || section_handle.is_some()
16376        || list_depth.is_some()
16377        || fence_language.is_some()
16378        || !embedded_symbols.is_empty())
16379    .then_some(MarkdownSpanMetadata {
16380        heading_level,
16381        section_path,
16382        section_handle,
16383        list_depth,
16384        fence_language,
16385        embedded_symbols,
16386    })
16387}
16388
16389fn markdown_symbol_hit_metadata(
16390    symbol: &index::SymbolHit,
16391    source: &[u8],
16392    start_byte: usize,
16393) -> Option<MarkdownSpanMetadata> {
16394    if symbol.language != "markdown" {
16395        return None;
16396    }
16397    let heading_level = (symbol.kind == "heading")
16398        .then(|| markdown_heading_level(source, start_byte))
16399        .flatten();
16400    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16401    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16402    let embedded_symbols = if symbol.kind == "code_block" {
16403        markdown_embedded_symbols(
16404            &symbol.file,
16405            source,
16406            symbol_span_byte(symbol.body_start_byte),
16407            symbol_span_byte(symbol.body_end_byte),
16408            fence_language.as_deref(),
16409        )
16410    } else {
16411        Vec::new()
16412    };
16413    (heading_level.is_some()
16414        || list_depth.is_some()
16415        || fence_language.is_some()
16416        || !embedded_symbols.is_empty())
16417    .then_some(MarkdownSpanMetadata {
16418        heading_level,
16419        section_path: Vec::new(),
16420        section_handle: None,
16421        list_depth,
16422        fence_language,
16423        embedded_symbols,
16424    })
16425}
16426
16427fn is_markdown_path(path: &Path) -> bool {
16428    path.extension()
16429        .and_then(|ext| ext.to_str())
16430        .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
16431        .unwrap_or(false)
16432}
16433
16434fn markdown_ast_block_kind(kind: &str) -> String {
16435    match kind {
16436        "heading" => "section",
16437        "code_block" => "fenced_code_block",
16438        "list_item" => "list_item",
16439        other => other,
16440    }
16441    .to_string()
16442}
16443
16444fn markdown_embedded_language_key(language: &str) -> Option<String> {
16445    let key = language
16446        .split_whitespace()
16447        .next()
16448        .unwrap_or("")
16449        .trim()
16450        .trim_start_matches("language-")
16451        .trim_start_matches("lang-")
16452        .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
16453        .to_ascii_lowercase();
16454    (!key.is_empty()).then_some(key)
16455}
16456
16457fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
16458    let key = markdown_embedded_language_key(language)?;
16459    let extension = match key.as_str() {
16460        "rust" => "rs",
16461        "python" => "py",
16462        "typescript" => "ts",
16463        "javascript" => "js",
16464        "kotlin" => "kt",
16465        "shell" | "sh" | "zsh" => "bash",
16466        other => other,
16467    };
16468    let lang = graph::Lang::from_extension(extension)?;
16469    (lang.name() != "markdown").then_some(lang)
16470}
16471
16472fn markdown_embedded_ast_span_handle(
16473    file: &str,
16474    language: &str,
16475    name: &str,
16476    kind: &str,
16477    start_byte: usize,
16478    end_byte: usize,
16479) -> String {
16480    stable_handle(
16481        "span",
16482        &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16483    )
16484}
16485
16486fn markdown_embedded_symbols(
16487    file: &str,
16488    source: &[u8],
16489    body_start_byte: Option<usize>,
16490    body_end_byte: Option<usize>,
16491    fence_language: Option<&str>,
16492) -> Vec<MarkdownEmbeddedSymbol> {
16493    let Some(fence_language) = fence_language else {
16494        return Vec::new();
16495    };
16496    let Some(lang) = markdown_embedded_lang(fence_language) else {
16497        return Vec::new();
16498    };
16499    let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16500        return Vec::new();
16501    };
16502    let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16503    else {
16504        return Vec::new();
16505    };
16506    if body.is_empty() {
16507        return Vec::new();
16508    }
16509
16510    let Ok(symbols) = lang.extract_symbols(body) else {
16511        return Vec::new();
16512    };
16513    let language = lang.name().to_string();
16514    symbols
16515        .into_iter()
16516        .map(|symbol| {
16517            let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16518            let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16519            let body_start = symbol
16520                .body_start_byte
16521                .map(|byte| body_start_byte.saturating_add(byte));
16522            let body_end = symbol
16523                .body_end_byte
16524                .map(|byte| body_start_byte.saturating_add(byte));
16525            let start_line = source_line_for_byte(source, start_byte);
16526            let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16527            MarkdownEmbeddedSymbol {
16528                handle: markdown_embedded_ast_span_handle(
16529                    file,
16530                    &language,
16531                    &symbol.name,
16532                    &symbol.kind,
16533                    start_byte,
16534                    end_byte,
16535                ),
16536                name: symbol.name,
16537                kind: symbol.kind,
16538                language: language.clone(),
16539                node_kind: symbol.node_kind,
16540                start_byte,
16541                end_byte,
16542                start_line,
16543                end_line,
16544                body_start_byte: body_start,
16545                body_end_byte: body_end,
16546                body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16547                body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16548            }
16549        })
16550        .collect()
16551}
16552
16553fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16554    let start = start_byte.min(source.len());
16555    let line_start = source[..start]
16556        .iter()
16557        .rposition(|value| *value == b'\n')
16558        .map(|pos| pos + 1)
16559        .unwrap_or(0);
16560    let line_end = source[start..]
16561        .iter()
16562        .position(|value| *value == b'\n')
16563        .map(|pos| start + pos)
16564        .unwrap_or(source.len());
16565    std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16566}
16567
16568fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16569    let line = markdown_source_line(source, start_byte);
16570    let trimmed = line.trim_start();
16571    for marker in ["-", "*", "+"] {
16572        if trimmed
16573            .strip_prefix(marker)
16574            .and_then(|rest| rest.strip_prefix(' '))
16575            .is_some()
16576        {
16577            return (Some(marker.to_string()), None);
16578        }
16579    }
16580
16581    let digit_end = trimmed
16582        .find(|ch: char| !ch.is_ascii_digit())
16583        .unwrap_or(trimmed.len());
16584    let (digits, rest) = trimmed.split_at(digit_end);
16585    if !digits.is_empty() {
16586        for marker in [".", ")"] {
16587            if rest
16588                .strip_prefix(marker)
16589                .and_then(|value| value.strip_prefix(' '))
16590                .is_some()
16591            {
16592                return (
16593                    Some(format!("{digits}{marker}")),
16594                    digits.parse::<usize>().ok(),
16595                );
16596            }
16597        }
16598    }
16599    (None, None)
16600}
16601
16602fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16603    let line = markdown_source_line(source, start_byte);
16604    let trimmed = line.trim_start();
16605    ["```", "~~~"]
16606        .into_iter()
16607        .find(|marker| trimmed.starts_with(marker))
16608        .map(str::to_string)
16609}
16610
16611fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16612    let mut nodes = graph::Lang::Markdown
16613        .extract_symbols(source)
16614        .context("extracting Markdown AST nodes")?
16615        .into_iter()
16616        .map(|symbol| {
16617            let body_start_byte = symbol.body_start_byte;
16618            let body_end_byte = symbol.body_end_byte;
16619            let span_handle = ast_span_handle(
16620                file,
16621                &symbol.name,
16622                &symbol.kind,
16623                symbol.start_byte,
16624                symbol.end_byte,
16625            );
16626            MarkdownAstRawNode {
16627                handle: stable_handle(
16628                    "mdast",
16629                    &format!(
16630                        "{}:{}:{}:{}:{}",
16631                        file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16632                    ),
16633                ),
16634                span_handle,
16635                name: symbol.name,
16636                kind: symbol.kind.clone(),
16637                block_kind: markdown_ast_block_kind(&symbol.kind),
16638                node_kind: symbol.node_kind,
16639                start_byte: symbol.start_byte,
16640                end_byte: symbol.end_byte,
16641                body_start_byte,
16642                body_end_byte,
16643            }
16644        })
16645        .collect::<Vec<_>>();
16646    nodes.sort_by(|left, right| {
16647        left.start_byte
16648            .cmp(&right.start_byte)
16649            .then(left.end_byte.cmp(&right.end_byte))
16650            .then(left.kind.cmp(&right.kind))
16651            .then(left.name.cmp(&right.name))
16652    });
16653    Ok(nodes)
16654}
16655
16656pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16657    let source_hash = blake3::hash(source).to_hex().to_string();
16658    let cache_key = format!("{file}:{source_hash}");
16659    let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16660    if let Some(entry) = cache
16661        .lock()
16662        .expect("markdown ast cache poisoned")
16663        .get(&cache_key)
16664    {
16665        return Ok(MarkdownAstProjection {
16666            source_hash: entry.source_hash.clone(),
16667            nodes: entry.nodes.clone(),
16668            parse_duration_micros: entry.parse_duration_micros,
16669            cache_hit: true,
16670        });
16671    }
16672
16673    let started = Instant::now();
16674    let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16675    let parse_duration_micros = started.elapsed().as_micros();
16676    cache.lock().expect("markdown ast cache poisoned").insert(
16677        cache_key,
16678        MarkdownAstCacheEntry {
16679            source_hash: source_hash.clone(),
16680            nodes: nodes.clone(),
16681            parse_duration_micros,
16682        },
16683    );
16684    Ok(MarkdownAstProjection {
16685        source_hash,
16686        nodes,
16687        parse_duration_micros,
16688        cache_hit: false,
16689    })
16690}
16691
16692fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16693    MarkdownAstCacheReport {
16694        source_hash: projection.source_hash.clone(),
16695        cache_hit: projection.cache_hit,
16696        parse_duration_micros: projection.parse_duration_micros,
16697        node_count: projection.nodes.len(),
16698        section_count: projection
16699            .nodes
16700            .iter()
16701            .filter(|node| node.kind == "heading")
16702            .count(),
16703        list_item_count: projection
16704            .nodes
16705            .iter()
16706            .filter(|node| node.kind == "list_item")
16707            .count(),
16708        code_block_count: projection
16709            .nodes
16710            .iter()
16711            .filter(|node| node.kind == "code_block")
16712            .count(),
16713    }
16714}
16715
16716fn markdown_ast_node_direct_child_count(
16717    node: &MarkdownAstRawNode,
16718    nodes: &[MarkdownAstRawNode],
16719) -> usize {
16720    nodes
16721        .iter()
16722        .filter(|candidate| {
16723            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16724        })
16725        .count()
16726}
16727
16728fn markdown_ast_outline_entry(
16729    root: &Path,
16730    file: &str,
16731    source: &[u8],
16732    nodes: &[MarkdownAstRawNode],
16733    node: &MarkdownAstRawNode,
16734    max_bytes: usize,
16735) -> MarkdownAstOutlineEntry {
16736    let line = source_line_for_byte(source, node.start_byte);
16737    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16738    MarkdownAstOutlineEntry {
16739        handle: node.handle.clone(),
16740        span_handle: node.span_handle.clone(),
16741        name: truncate_for_budget(&node.name, max_bytes),
16742        kind: node.kind.clone(),
16743        block_kind: node.block_kind.clone(),
16744        line,
16745        end_line,
16746        section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16747        child_count: markdown_ast_node_direct_child_count(node, nodes),
16748        expand: markdown_ast_command(root, file, Some(&node.handle)),
16749    }
16750}
16751
16752fn markdown_ast_outline_entries(
16753    root: &Path,
16754    file: &str,
16755    source: &[u8],
16756    nodes: &[MarkdownAstRawNode],
16757    limit: usize,
16758    max_bytes: usize,
16759) -> Vec<MarkdownAstOutlineEntry> {
16760    let mut headings = nodes
16761        .iter()
16762        .filter(|node| node.kind == "heading")
16763        .collect::<Vec<_>>();
16764    let mut blocks = nodes
16765        .iter()
16766        .filter(|node| node.kind != "heading")
16767        .collect::<Vec<_>>();
16768    headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16769    blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16770    headings
16771        .into_iter()
16772        .chain(blocks)
16773        .take(limit)
16774        .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16775        .collect()
16776}
16777
16778fn markdown_ast_node_intersects_lines(
16779    source: &[u8],
16780    node: &MarkdownAstRawNode,
16781    start: usize,
16782    end: usize,
16783) -> bool {
16784    let line = source_line_for_byte(source, node.start_byte);
16785    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16786    line <= end && end_line >= start
16787}
16788
16789fn source_read_markdown_projection(
16790    root: &Path,
16791    file: &str,
16792    source: &[u8],
16793    start: usize,
16794    end: usize,
16795    budget: ResponseBudget,
16796) -> Result<SourceReadMarkdownProjection> {
16797    let projection = markdown_ast_projection(file, source)?;
16798    let visible_nodes = projection
16799        .nodes
16800        .iter()
16801        .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16802        .collect::<Vec<_>>();
16803    let mut outline_nodes = visible_nodes.clone();
16804    outline_nodes.sort_by_key(|node| {
16805        (
16806            node.kind != "heading",
16807            node.start_byte,
16808            node.end_byte,
16809            node.name.as_str(),
16810        )
16811    });
16812    let outline = outline_nodes
16813        .into_iter()
16814        .take(budget.preview_items())
16815        .map(|node| {
16816            markdown_ast_outline_entry(
16817                root,
16818                file,
16819                source,
16820                &projection.nodes,
16821                node,
16822                budget.preview_bytes(),
16823            )
16824        })
16825        .collect::<Vec<_>>();
16826    Ok(SourceReadMarkdownProjection {
16827        handle: stable_handle(
16828            "mdproj",
16829            &format!("{file}:{start}:{end}:{}", projection.source_hash),
16830        ),
16831        mode: "window_outline".to_string(),
16832        total_nodes: projection.nodes.len(),
16833        visible_nodes: visible_nodes.len(),
16834        outline,
16835        expand: markdown_ast_command(root, file, None),
16836    })
16837}
16838
16839fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16840    if parent.handle == child.handle {
16841        return false;
16842    }
16843    parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16844}
16845
16846fn markdown_ast_parent_handle(
16847    node: &MarkdownAstRawNode,
16848    nodes: &[MarkdownAstRawNode],
16849) -> Option<String> {
16850    nodes
16851        .iter()
16852        .filter(|candidate| markdown_ast_contains(candidate, node))
16853        .min_by_key(|candidate| {
16854            (
16855                candidate.end_byte.saturating_sub(candidate.start_byte),
16856                candidate.start_byte,
16857            )
16858        })
16859        .map(|candidate| candidate.handle.clone())
16860}
16861
16862fn markdown_ast_child_handles(
16863    node: &MarkdownAstRawNode,
16864    nodes: &[MarkdownAstRawNode],
16865    limit: usize,
16866) -> Vec<String> {
16867    nodes
16868        .iter()
16869        .filter(|candidate| {
16870            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16871        })
16872        .take(limit)
16873        .map(|candidate| candidate.handle.clone())
16874        .collect()
16875}
16876
16877fn markdown_ast_section_nodes<'a>(
16878    node: &MarkdownAstRawNode,
16879    nodes: &'a [MarkdownAstRawNode],
16880) -> Vec<&'a MarkdownAstRawNode> {
16881    let mut headings = nodes
16882        .iter()
16883        .filter(|candidate| candidate.kind == "heading")
16884        .filter(|candidate| {
16885            candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16886        })
16887        .collect::<Vec<_>>();
16888    headings.sort_by(|left, right| {
16889        left.start_byte
16890            .cmp(&right.start_byte)
16891            .then(left.end_byte.cmp(&right.end_byte))
16892            .then(left.name.cmp(&right.name))
16893    });
16894    headings
16895}
16896
16897fn markdown_ast_node_metadata(
16898    file: &str,
16899    node: &MarkdownAstRawNode,
16900    source: &[u8],
16901    nodes: &[MarkdownAstRawNode],
16902) -> MarkdownAstNodeMetadata {
16903    let section_nodes = markdown_ast_section_nodes(node, nodes);
16904    let section_path = section_nodes
16905        .iter()
16906        .map(|heading| heading.name.clone())
16907        .collect::<Vec<_>>();
16908    let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16909    let heading_level = (node.kind == "heading")
16910        .then(|| markdown_heading_level(source, node.start_byte))
16911        .flatten();
16912    let (list_marker, list_order) = if node.kind == "list_item" {
16913        markdown_list_attributes(source, node.start_byte)
16914    } else {
16915        (None, None)
16916    };
16917    let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16918    let embedded_symbols = if node.kind == "code_block" {
16919        markdown_embedded_symbols(
16920            file,
16921            source,
16922            node.body_start_byte,
16923            node.body_end_byte,
16924            fence_language.as_deref(),
16925        )
16926    } else {
16927        Vec::new()
16928    };
16929    MarkdownAstNodeMetadata {
16930        heading_level,
16931        section_path,
16932        section_handle,
16933        list_depth: (node.kind == "list_item")
16934            .then(|| markdown_list_depth(source, node.start_byte)),
16935        list_marker,
16936        list_order,
16937        fence_language,
16938        fence_marker: (node.kind == "code_block")
16939            .then(|| markdown_fence_marker(source, node.start_byte))
16940            .flatten(),
16941        embedded_symbols,
16942    }
16943}
16944
16945fn markdown_ast_node_expand(
16946    root: &Path,
16947    file: &str,
16948    node: &MarkdownAstRawNode,
16949    source: &[u8],
16950) -> MarkdownAstNodeExpand {
16951    let start_line = source_line_for_byte(source, node.start_byte);
16952    let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16953    let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16954    let body_start_line = node
16955        .body_start_byte
16956        .map(|byte| source_line_for_byte(source, byte))
16957        .unwrap_or(start_line);
16958    let body_end_line = node
16959        .body_end_byte
16960        .map(|byte| source_line_for_end_byte(source, byte))
16961        .unwrap_or(end_line)
16962        .max(body_start_line);
16963    let body_line_count = body_end_line
16964        .saturating_sub(body_start_line)
16965        .saturating_add(1)
16966        .max(1);
16967    MarkdownAstNodeExpand {
16968        source_window: source_read_command(root, file, start_line, line_count),
16969        source_body: source_read_command(root, file, body_start_line, body_line_count),
16970        symbol_read: source_symbol_read_command(root, &node.name, file),
16971        edit_intents: markdown_edit_intents_command(root),
16972    }
16973}
16974
16975fn markdown_ast_node(
16976    root: &Path,
16977    file: &str,
16978    node: &MarkdownAstRawNode,
16979    source: &[u8],
16980    nodes: &[MarkdownAstRawNode],
16981    child_limit: usize,
16982) -> MarkdownAstNode {
16983    let line = source_line_for_byte(source, node.start_byte);
16984    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16985    let body_byte_span = node
16986        .body_start_byte
16987        .zip(node.body_end_byte)
16988        .map(|(start, end)| SourceByteRangePreview { start, end });
16989    MarkdownAstNode {
16990        handle: node.handle.clone(),
16991        span_handle: node.span_handle.clone(),
16992        name: node.name.clone(),
16993        kind: node.kind.clone(),
16994        block_kind: node.block_kind.clone(),
16995        node_kind: node.node_kind.clone(),
16996        line,
16997        end_line,
16998        byte_span: SourceByteRangePreview {
16999            start: node.start_byte,
17000            end: node.end_byte,
17001        },
17002        body_byte_span,
17003        parent_handle: markdown_ast_parent_handle(node, nodes),
17004        child_handles: markdown_ast_child_handles(node, nodes, child_limit),
17005        metadata: markdown_ast_node_metadata(file, node, source, nodes),
17006        expand: markdown_ast_node_expand(root, file, node, source),
17007    }
17008}
17009
17010pub(crate) fn stored_symbol_ast_span(
17011    symbol: &index::StoredSymbol,
17012    source: &[u8],
17013    symbols: &[index::StoredSymbol],
17014    child_limit: usize,
17015) -> Option<AstSpanPreview> {
17016    let file_symbols = symbols.iter().collect::<Vec<_>>();
17017    stored_symbol_ast_span_in_file(symbol, source, &file_symbols, child_limit)
17018}
17019
17020fn stored_symbol_ast_span_in_file(
17021    symbol: &index::StoredSymbol,
17022    source: &[u8],
17023    symbols: &[&index::StoredSymbol],
17024    child_limit: usize,
17025) -> Option<AstSpanPreview> {
17026    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
17027    let node_kind = symbol.node_kind.clone()?;
17028    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17029    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17030    Some(AstSpanPreview {
17031        handle: ast_span_handle(
17032            &symbol.file,
17033            &symbol.name,
17034            &symbol.kind,
17035            start_byte,
17036            end_byte,
17037        ),
17038        node_kind,
17039        start_byte,
17040        end_byte,
17041        start_line: source_line_for_byte(source, start_byte),
17042        end_line: source_line_for_end_byte(source, end_byte),
17043        body_start_byte,
17044        body_end_byte,
17045        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17046        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17047        parent_handle: stored_symbol_parent_span_handle_in_file(symbol, symbols),
17048        child_handles: stored_symbol_child_span_handles_in_file(symbol, symbols, child_limit),
17049        markdown: markdown_stored_symbol_metadata_in_file(symbol, source, symbols),
17050    })
17051}
17052
17053pub(crate) fn symbol_hit_ast_span(
17054    symbol: &index::SymbolHit,
17055    source: &[u8],
17056) -> Option<AstSpanPreview> {
17057    let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
17058    let node_kind = symbol.node_kind.clone()?;
17059    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17060    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17061    Some(AstSpanPreview {
17062        handle: ast_span_handle(
17063            &symbol.file,
17064            &symbol.name,
17065            &symbol.kind,
17066            start_byte,
17067            end_byte,
17068        ),
17069        node_kind,
17070        start_byte,
17071        end_byte,
17072        start_line: source_line_for_byte(source, start_byte),
17073        end_line: source_line_for_end_byte(source, end_byte),
17074        body_start_byte,
17075        body_end_byte,
17076        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17077        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17078        parent_handle: None,
17079        child_handles: Vec::new(),
17080        markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
17081    })
17082}
17083
17084pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
17085    usize::try_from(symbol.line)
17086        .ok()
17087        .and_then(|line| line.checked_add(1))
17088        .unwrap_or(1)
17089}
17090
17091pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
17092    symbol
17093        .end_line
17094        .and_then(|line| usize::try_from(line).ok())
17095        .and_then(|line| line.checked_add(1))
17096}
17097
17098fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
17099    if end == 0 {
17100        return false;
17101    }
17102    let symbol_start = source_symbol_line(symbol);
17103    let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
17104    symbol_start <= end && symbol_end >= start
17105}
17106
17107#[allow(clippy::too_many_arguments)]
17108fn load_source_symbols(
17109    root: &Path,
17110    file_abs: &Path,
17111    file_display: &str,
17112    source: &[u8],
17113    scope: Option<&str>,
17114    start: usize,
17115    end: usize,
17116    limit: usize,
17117    max_bytes: usize,
17118    warnings: &mut Vec<String>,
17119) -> Vec<SourceSymbolRef> {
17120    let target = match resolve_query_index_target(root, file_abs, scope) {
17121        Ok(target) => target,
17122        Err(err) => {
17123            warnings.push(format!("index refs unavailable: {err:#}"));
17124            return Vec::new();
17125        }
17126    };
17127    // Build/refresh the per-package cargo index on demand so source-read delivers
17128    // AST symbol refs for any workspace member — not only members a prior
17129    // graph/search/explain query happened to index. `open_index_db` (the
17130    // search/explain/graph path) already ensures the index is current; before
17131    // this, source-read only checked `db_path.exists()`, so a workspace member
17132    // that had never been queried (≈60% of members here) silently degraded to
17133    // window-only output with an "index refs unavailable" warning even though
17134    // `tsift status` reported the index fresh (#cargoidxcov).
17135    if let Err(err) = ensure_query_index_current(root, &target) {
17136        warnings.push(format!("index refs unavailable: {err:#}"));
17137        return Vec::new();
17138    }
17139    let db_path = target.db_path;
17140    if !db_path.exists() {
17141        warnings.push(format!(
17142            "index refs unavailable: no index found at {}",
17143            db_path.display()
17144        ));
17145        return Vec::new();
17146    }
17147
17148    let db = match index::IndexDb::open_read_only_resilient(&db_path) {
17149        Ok(db) => db,
17150        Err(err) => {
17151            warnings.push(format!("index refs unavailable: {err:#}"));
17152            return Vec::new();
17153        }
17154    };
17155
17156    let file_key = file_abs.to_string_lossy().to_string();
17157    let symbols = match db.symbols_for_file(&file_key) {
17158        Ok(symbols) => symbols,
17159        Err(err) => {
17160            warnings.push(format!("symbol refs unavailable: {err:#}"));
17161            return Vec::new();
17162        }
17163    };
17164
17165    symbols
17166        .iter()
17167        .filter(|symbol| source_symbol_intersects(symbol, start, end))
17168        .take(limit)
17169        .map(|symbol| {
17170            let line = source_symbol_line(symbol);
17171            let end_line = source_symbol_end_line(symbol);
17172            let handle = stable_handle(
17173                "ssym",
17174                &format!("{}:{}:{}", file_display, symbol.name, line),
17175            );
17176            SourceSymbolRef {
17177                handle,
17178                name: truncate_for_budget(&symbol.name, max_bytes),
17179                kind: symbol.kind.clone(),
17180                language: symbol.language.clone(),
17181                file: file_display.to_string(),
17182                line,
17183                end_line,
17184                signature: symbol
17185                    .signature
17186                    .clone()
17187                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
17188                span: stored_symbol_ast_span(symbol, source, &symbols, limit),
17189                expand: source_symbol_read_command(root, &symbol.name, file_display),
17190            }
17191        })
17192        .collect()
17193}
17194
17195fn load_source_summaries(
17196    root: &Path,
17197    file_display: &str,
17198    limit: usize,
17199    max_bytes: usize,
17200    warnings: &mut Vec<String>,
17201) -> Vec<SourceSummaryRef> {
17202    let db_path = root.join(".tsift/summaries.db");
17203    if !db_path.exists() {
17204        return Vec::new();
17205    }
17206    let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
17207        Ok(db) => db,
17208        Err(err) => {
17209            warnings.push(format!("summary refs unavailable: {err:#}"));
17210            return Vec::new();
17211        }
17212    };
17213    let summaries = match db.get_by_file(file_display) {
17214        Ok(summaries) => summaries,
17215        Err(err) => {
17216            warnings.push(format!("summary refs unavailable: {err:#}"));
17217            return Vec::new();
17218        }
17219    };
17220
17221    summaries
17222        .into_iter()
17223        .take(limit)
17224        .map(|summary| SourceSummaryRef {
17225            handle: stable_handle(
17226                "sum",
17227                &format!(
17228                    "{}:{}:{}",
17229                    summary.file_path, summary.symbol_name, summary.id
17230                ),
17231            ),
17232            symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
17233            file_path: summary.file_path,
17234            summary: truncate_for_budget(&summary.summary, max_bytes),
17235            expand: source_summary_expand_command(root, &summary.symbol_name),
17236        })
17237        .collect()
17238}
17239
17240fn cmd_markdown_ast(
17241    file: &Path,
17242    path: &Path,
17243    node: Option<&str>,
17244    format: OutputFormat,
17245    absolute: bool,
17246    budget: ResponseBudget,
17247) -> Result<()> {
17248    let root = lint::resolve_project_root_or_canonical_path(path)?;
17249    let file_abs = resolve_source_file(&root, file)?;
17250    if !is_markdown_path(&file_abs) {
17251        bail!(
17252            "markdown-ast only supports Markdown files (.md/.mdx): {}",
17253            file_abs.display()
17254        );
17255    }
17256    let file_display = if absolute {
17257        file_abs.to_string_lossy().to_string()
17258    } else {
17259        relativize_pathbuf(&file_abs, &root)
17260            .to_string_lossy()
17261            .to_string()
17262    };
17263    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17264    let text = String::from_utf8_lossy(&source);
17265    let total_lines = text.lines().count();
17266    let projection = markdown_ast_projection(&file_display, &source)?;
17267    let raw_nodes = &projection.nodes;
17268    let max_items = budget.preview_items();
17269    let max_bytes = budget.preview_bytes();
17270
17271    let selected_nodes = if let Some(handle) = node {
17272        let matches = raw_nodes
17273            .iter()
17274            .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
17275            .collect::<Vec<_>>();
17276        if matches.is_empty() {
17277            bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
17278        }
17279        matches
17280    } else {
17281        raw_nodes.iter().take(max_items).collect::<Vec<_>>()
17282    };
17283    let nodes = selected_nodes
17284        .into_iter()
17285        .map(|raw| {
17286            let mut node =
17287                markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
17288            node.name = truncate_for_budget(&node.name, max_bytes);
17289            node
17290        })
17291        .collect::<Vec<_>>();
17292    let outline_started = Instant::now();
17293    let outline = markdown_ast_outline_entries(
17294        &root,
17295        &file_display,
17296        &source,
17297        raw_nodes,
17298        max_items,
17299        max_bytes,
17300    );
17301    let outline_duration_micros = outline_started.elapsed().as_micros();
17302    let projection_preview = MarkdownAstProjectionPreview {
17303        mode: if node.is_some() {
17304            "selected_node".to_string()
17305        } else {
17306            "outline_first".to_string()
17307        },
17308        total_nodes: raw_nodes.len(),
17309        returned_nodes: nodes.len(),
17310        omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
17311        selected_node: node.map(str::to_string),
17312        cache: markdown_ast_cache_report(&projection),
17313        outline,
17314        phase_timings: vec![
17315            MarkdownAstPhaseTiming {
17316                name: "parse_extract".to_string(),
17317                duration_micros: projection.parse_duration_micros,
17318                detail: if projection.cache_hit {
17319                    "reused cached tree-sitter Markdown symbol extraction".to_string()
17320                } else {
17321                    "tree-sitter Markdown symbol extraction".to_string()
17322                },
17323            },
17324            MarkdownAstPhaseTiming {
17325                name: "outline_projection".to_string(),
17326                duration_micros: outline_duration_micros,
17327                detail: "outline-first section/block preview construction".to_string(),
17328            },
17329        ],
17330    };
17331    let report = MarkdownAstReport {
17332        handle: stable_handle("mdastrep", &file_display),
17333        root: root.to_string_lossy().to_string(),
17334        file: file_display.clone(),
17335        range: SourceRangePreview {
17336            start: 1,
17337            end: total_lines,
17338            total_lines,
17339            truncated_before: false,
17340            truncated_after: false,
17341        },
17342        projection: projection_preview,
17343        nodes,
17344        expand: MarkdownAstExpandCommands {
17345            file: markdown_ast_command(&root, &file_display, None),
17346            source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
17347            edit_intents: markdown_edit_intents_command(&root),
17348        },
17349        warnings: Vec::new(),
17350    };
17351
17352    if format.json_output {
17353        let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
17354        let mut follow_up = vec![
17355            report.expand.file.clone(),
17356            report.expand.source_read.clone(),
17357            report.expand.edit_intents.clone(),
17358        ];
17359        follow_up.extend(
17360            report
17361                .nodes
17362                .iter()
17363                .map(|node| node.expand.source_window.clone()),
17364        );
17365        print_json_or_envelope(
17366            &report,
17367            &format,
17368            "markdown-ast",
17369            "ast",
17370            ToolEnvelopeSummary {
17371                text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
17372                metrics: vec![
17373                    envelope_metric("nodes", report.nodes.len()),
17374                    envelope_metric("total_nodes", report.projection.total_nodes),
17375                    envelope_metric(
17376                        "parse_duration_micros",
17377                        report.projection.cache.parse_duration_micros,
17378                    ),
17379                    envelope_metric("total_lines", report.range.total_lines),
17380                ],
17381            },
17382            truncated,
17383            follow_up,
17384        )?;
17385    } else if format.compact {
17386        println!(
17387            "markdown-ast {} nodes:{} handle:{}",
17388            report.file,
17389            report.nodes.len(),
17390            report.handle
17391        );
17392        for node in &report.nodes {
17393            println!(
17394                "  {} {} {}:{}-{}",
17395                node.handle, node.kind, node.name, node.line, node.end_line
17396            );
17397        }
17398        if node.is_none() && raw_nodes.len() > report.nodes.len() {
17399            println!("expand: {}", report.expand.file);
17400        }
17401    } else {
17402        println!(
17403            "Markdown AST `{}` nodes {} of {} ({})",
17404            report.file,
17405            report.nodes.len(),
17406            raw_nodes.len(),
17407            report.handle
17408        );
17409        for node in &report.nodes {
17410            println!(
17411                "  {} `{}` {}:{}-{} — {}",
17412                node.handle,
17413                node.name,
17414                node.kind,
17415                node.line,
17416                node.end_line,
17417                node.expand.source_window
17418            );
17419        }
17420        if node.is_none() && raw_nodes.len() > report.nodes.len() {
17421            println!();
17422            println!("Expand:");
17423            println!("  file: {}", report.expand.file);
17424        }
17425    }
17426
17427    Ok(())
17428}
17429
17430#[allow(clippy::too_many_arguments)]
17431fn cmd_source_read(
17432    file: &Path,
17433    path: &Path,
17434    style: SourceReadStyle,
17435    start: usize,
17436    lines: usize,
17437    end: Option<usize>,
17438    scope: Option<&str>,
17439    format: OutputFormat,
17440    absolute: bool,
17441    budget: ResponseBudget,
17442) -> Result<()> {
17443    if start == 0 {
17444        bail!("--start is 1-based and must be greater than zero");
17445    }
17446    if lines == 0 {
17447        bail!("--lines must be greater than zero");
17448    }
17449    if let Some(end) = end
17450        && end < start
17451    {
17452        bail!("--end must be greater than or equal to --start");
17453    }
17454
17455    let root = lint::resolve_project_root_or_canonical_path(path)?;
17456    let file_abs = resolve_source_file(&root, file)?;
17457    let file_display = if absolute {
17458        file_abs.to_string_lossy().to_string()
17459    } else {
17460        relativize_pathbuf(&file_abs, &root)
17461            .to_string_lossy()
17462            .to_string()
17463    };
17464
17465    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17466    let text = String::from_utf8_lossy(&source);
17467    let all_lines: Vec<&str> = text.lines().collect();
17468    let total_lines = all_lines.len();
17469    if total_lines > 0 && start > total_lines {
17470        bail!(
17471            "--start {} is beyond end of {} ({} lines)",
17472            start,
17473            file_display,
17474            total_lines
17475        );
17476    }
17477    let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17478    let end_line = requested_end.min(total_lines);
17479    let mut warnings = Vec::new();
17480    let max_items = budget.preview_items();
17481    let max_bytes = budget.preview_bytes();
17482    if style == SourceReadStyle::Ast {
17483        let symbols = load_source_symbols(
17484            &root,
17485            &file_abs,
17486            &file_display,
17487            &source,
17488            scope,
17489            start,
17490            end_line,
17491            max_items,
17492            max_bytes,
17493            &mut warnings,
17494        );
17495        let summaries =
17496            load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17497        let markdown = if is_markdown_path(&file_abs) {
17498            match source_read_markdown_projection(
17499                &root,
17500                &file_display,
17501                &source,
17502                start,
17503                end_line,
17504                budget,
17505            ) {
17506                Ok(markdown) => Some(markdown),
17507                Err(err) => {
17508                    warnings.push(format!("markdown projection unavailable: {err:#}"));
17509                    None
17510                }
17511            }
17512        } else {
17513            None
17514        };
17515        let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17516        let report = SourceReadAstReport {
17517            handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17518            root: root.to_string_lossy().to_string(),
17519            file: file_display.clone(),
17520            range: SourceRangePreview {
17521                start,
17522                end: end_line,
17523                total_lines,
17524                truncated_before: start > 1,
17525                truncated_after: end_line < total_lines,
17526            },
17527            symbols,
17528            summaries,
17529            markdown,
17530            expand: SourceReadAstExpandCommands {
17531                window: source_read_window_command(&root, &file_display, start, window_lines),
17532                file_window: source_read_window_command(
17533                    &root,
17534                    &file_display,
17535                    1,
17536                    total_lines.max(window_lines),
17537                ),
17538                markdown_ast: is_markdown_path(&file_abs)
17539                    .then(|| markdown_ast_command(&root, &file_display, None)),
17540            },
17541            warnings,
17542        };
17543
17544        if format.json_output {
17545            let truncated = report.range.truncated_before
17546                || report.range.truncated_after
17547                || report.symbols.len() >= max_items
17548                || report.summaries.len() >= max_items;
17549            let follow_up = [
17550                Some(report.expand.window.clone()),
17551                Some(report.expand.file_window.clone()),
17552                report.expand.markdown_ast.clone(),
17553            ]
17554            .into_iter()
17555            .flatten()
17556            .collect::<Vec<_>>();
17557            print_json_or_envelope(
17558                &report,
17559                &format,
17560                "source-read",
17561                "ast",
17562                ToolEnvelopeSummary {
17563                    text: format!(
17564                        "source ast {}:{}-{}",
17565                        report.file, report.range.start, report.range.end
17566                    ),
17567                    metrics: vec![
17568                        envelope_metric("symbols", report.symbols.len()),
17569                        envelope_metric("summaries", report.summaries.len()),
17570                        envelope_metric(
17571                            "markdown_nodes",
17572                            report
17573                                .markdown
17574                                .as_ref()
17575                                .map_or(0, |markdown| markdown.visible_nodes),
17576                        ),
17577                    ],
17578                },
17579                truncated,
17580                follow_up,
17581            )?;
17582        } else if format.compact {
17583            println!(
17584                "source-ast {}:{}-{} / {} handle:{}",
17585                report.file,
17586                report.range.start,
17587                report.range.end,
17588                report.range.total_lines,
17589                report.handle
17590            );
17591            for symbol in &report.symbols {
17592                println!(
17593                    "  {} {}:{} {}",
17594                    symbol.name, symbol.file, symbol.line, symbol.expand
17595                );
17596            }
17597            if !report.summaries.is_empty() {
17598                println!("summaries[{}]", report.summaries.len());
17599            }
17600            for warning in &report.warnings {
17601                eprintln!("warning: {warning}");
17602            }
17603        } else {
17604            println!(
17605                "Source AST `{}` lines {}-{} of {} ({})",
17606                report.file,
17607                report.range.start,
17608                report.range.end,
17609                report.range.total_lines,
17610                report.handle
17611            );
17612            if !report.symbols.is_empty() {
17613                println!();
17614                println!("Symbol refs:");
17615                for symbol in &report.symbols {
17616                    println!(
17617                        "  {} `{}` {}:{} — {}",
17618                        symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17619                    );
17620                }
17621            }
17622            if !report.summaries.is_empty() {
17623                println!();
17624                println!("Summary refs:");
17625                for summary in &report.summaries {
17626                    println!(
17627                        "  {} `{}` — {}",
17628                        summary.handle, summary.symbol_name, summary.expand
17629                    );
17630                }
17631            }
17632            println!();
17633            println!("Expand:");
17634            println!("  window:      {}", report.expand.window);
17635            println!("  file window: {}", report.expand.file_window);
17636            if let Some(markdown_ast) = &report.expand.markdown_ast {
17637                println!("  markdown:    {}", markdown_ast);
17638            }
17639            for warning in &report.warnings {
17640                eprintln!("warning: {warning}");
17641            }
17642        }
17643
17644        return Ok(());
17645    }
17646    let max_bytes = budget.preview_bytes();
17647    let token_cap = budget.body_token_cap();
17648    let (preview, preview_end, body_truncated) = if total_lines == 0 {
17649        (Vec::new(), end_line, false)
17650    } else {
17651        let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17652        (capped.preview, capped.capped_end, capped.was_capped)
17653    };
17654    let effective_end = if body_truncated {
17655        preview_end
17656    } else {
17657        end_line
17658    };
17659
17660    if body_truncated {
17661        warnings.push(format!(
17662            "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17663        ));
17664    }
17665    let symbols = load_source_symbols(
17666        &root,
17667        &file_abs,
17668        &file_display,
17669        &source,
17670        scope,
17671        start,
17672        effective_end,
17673        max_items,
17674        max_bytes,
17675        &mut warnings,
17676    );
17677    let summaries =
17678        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17679    let markdown = if is_markdown_path(&file_abs) {
17680        match source_read_markdown_projection(
17681            &root,
17682            &file_display,
17683            &source,
17684            start,
17685            effective_end,
17686            budget,
17687        ) {
17688            Ok(markdown) => Some(markdown),
17689            Err(err) => {
17690                warnings.push(format!("markdown projection unavailable: {err:#}"));
17691                None
17692            }
17693        }
17694    } else {
17695        None
17696    };
17697
17698    let expand = SourceExpandCommands {
17699        before: (start > 1).then(|| {
17700            let before_start = start.saturating_sub(lines).max(1);
17701            source_read_window_command(&root, &file_display, before_start, start - before_start)
17702        }),
17703        after: (effective_end < total_lines)
17704            .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17705        body: body_truncated.then(|| {
17706            let remaining = end_line.saturating_sub(effective_end);
17707            source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17708        }),
17709        file: source_read_ast_command(&root, &file_display),
17710        markdown_ast: is_markdown_path(&file_abs)
17711            .then(|| markdown_ast_command(&root, &file_display, None)),
17712    };
17713
17714    let report = SourceReadReport {
17715        handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17716        root: root.to_string_lossy().to_string(),
17717        file: file_display,
17718        range: SourceRangePreview {
17719            start,
17720            end: effective_end,
17721            total_lines,
17722            truncated_before: start > 1,
17723            truncated_after: effective_end < total_lines,
17724        },
17725        preview,
17726        symbols,
17727        summaries,
17728        markdown,
17729        expand,
17730        warnings,
17731    };
17732
17733    if format.json_output {
17734        let truncated = report.range.truncated_before || report.range.truncated_after;
17735        let follow_up = [
17736            report.expand.before.clone(),
17737            report.expand.after.clone(),
17738            report.expand.body.clone(),
17739            Some(report.expand.file.clone()),
17740            report.expand.markdown_ast.clone(),
17741        ]
17742        .into_iter()
17743        .flatten()
17744        .collect::<Vec<_>>();
17745        print_json_or_envelope(
17746            &report,
17747            &format,
17748            "source-read",
17749            "window",
17750            ToolEnvelopeSummary {
17751                text: format!(
17752                    "source window {}:{}-{}",
17753                    report.file, report.range.start, report.range.end
17754                ),
17755                metrics: vec![
17756                    envelope_metric("lines", report.preview.len()),
17757                    envelope_metric("symbols", report.symbols.len()),
17758                    envelope_metric("summaries", report.summaries.len()),
17759                    envelope_metric(
17760                        "markdown_nodes",
17761                        report
17762                            .markdown
17763                            .as_ref()
17764                            .map_or(0, |markdown| markdown.visible_nodes),
17765                    ),
17766                ],
17767            },
17768            truncated,
17769            follow_up,
17770        )?;
17771    } else if format.compact {
17772        println!(
17773            "source {}:{}-{} / {} handle:{}",
17774            report.file,
17775            report.range.start,
17776            report.range.end,
17777            report.range.total_lines,
17778            report.handle
17779        );
17780        for line in &report.preview {
17781            println!("{:>5} {}", line.line, line.text);
17782        }
17783        if !report.symbols.is_empty() {
17784            println!("syms[{}]:", report.symbols.len());
17785            for symbol in &report.symbols {
17786                println!("  {} {}:{}", symbol.name, symbol.file, symbol.line);
17787            }
17788        }
17789        if report.range.truncated_before || report.range.truncated_after {
17790            println!("expand: {}", report.expand.file);
17791        }
17792    } else {
17793        println!(
17794            "Source window `{}` lines {}-{} of {} ({})",
17795            report.file,
17796            report.range.start,
17797            report.range.end,
17798            report.range.total_lines,
17799            report.handle
17800        );
17801        for line in &report.preview {
17802            println!("{:>5} | {}", line.line, line.text);
17803        }
17804        if !report.symbols.is_empty() {
17805            println!();
17806            println!("Symbol refs:");
17807            for symbol in &report.symbols {
17808                println!(
17809                    "  {} `{}` {}:{} — {}",
17810                    symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17811                );
17812            }
17813        }
17814        if !report.summaries.is_empty() {
17815            println!();
17816            println!("Summary refs:");
17817            for summary in &report.summaries {
17818                println!(
17819                    "  {} `{}` — {}",
17820                    summary.handle, summary.symbol_name, summary.expand
17821                );
17822            }
17823        }
17824        if report.range.truncated_before || report.range.truncated_after {
17825            println!();
17826            println!("Expand:");
17827            if let Some(before) = &report.expand.before {
17828                println!("  before: {}", before);
17829            }
17830            if let Some(after) = &report.expand.after {
17831                println!("  after: {}", after);
17832            }
17833            println!("  file:   {}", report.expand.file);
17834        }
17835        for warning in &report.warnings {
17836            eprintln!("warning: {warning}");
17837        }
17838    }
17839
17840    Ok(())
17841}
17842
17843#[allow(clippy::too_many_arguments)]
17844fn cmd_symbol_read(
17845    symbol: &str,
17846    file_hint: Option<&Path>,
17847    path: &Path,
17848    scope: Option<&str>,
17849    format: OutputFormat,
17850    absolute: bool,
17851    budget: ResponseBudget,
17852) -> Result<()> {
17853    let root = lint::resolve_project_root_or_canonical_path(path)?;
17854    let hinted_file_abs = file_hint
17855        .map(|file| resolve_source_file(&root, file))
17856        .transpose()?;
17857    let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17858    // Build/refresh the per-package cargo index on demand so symbol-read resolves
17859    // symbols in any workspace member, not only ones a prior graph/search query
17860    // indexed. Previously this checked existence only and bailed with "no index
17861    // found" for never-queried members despite a fresh `tsift status`
17862    // (#cargoidxcov).
17863    let target = resolve_query_index_target(&root, path_hint, scope)?;
17864    ensure_query_index_current(&root, &target)?;
17865    let db_path = target.db_path;
17866    if !db_path.exists() {
17867        bail!(
17868            "index refs unavailable: no index found at {}",
17869            db_path.display()
17870        );
17871    }
17872    let db = index::IndexDb::open_read_only_resilient(&db_path)
17873        .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17874    let search_limit = budget.follow_up_items().max(10);
17875    let hits = db
17876        .symbol_search(symbol, search_limit)
17877        .with_context(|| format!("searching symbols for {symbol:?}"))?;
17878    let selected = hits
17879        .into_iter()
17880        .find(|hit| {
17881            let Some(hinted_file_abs) = &hinted_file_abs else {
17882                return true;
17883            };
17884            resolve_source_file(&root, Path::new(&hit.file))
17885                .map(|hit_file| hit_file == *hinted_file_abs)
17886                .unwrap_or(false)
17887        })
17888        .with_context(|| {
17889            let hint = file_hint
17890                .map(|file| format!(" in {}", file.display()))
17891                .unwrap_or_default();
17892            format!("no indexed symbol matched {symbol:?}{hint}")
17893        })?;
17894
17895    let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17896    let file_display = if absolute {
17897        file_abs.to_string_lossy().to_string()
17898    } else {
17899        relativize_pathbuf(&file_abs, &root)
17900            .to_string_lossy()
17901            .to_string()
17902    };
17903    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17904    let content_hash = blake3::hash(&source).to_hex().to_string();
17905    let text = String::from_utf8_lossy(&source);
17906    let all_lines: Vec<&str> = text.lines().collect();
17907    let total_lines = all_lines.len();
17908    let file_symbols = db
17909        .symbols_for_file(&file_abs.to_string_lossy())
17910        .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17911    let max_items = budget.preview_items();
17912    let max_bytes = budget.preview_bytes();
17913    let selected_start = symbol_hit_line(&selected);
17914    let selected_end = symbol_hit_end_line(&selected)
17915        .unwrap_or(selected_start)
17916        .max(selected_start);
17917    let stored_target = file_symbols.iter().find(|candidate| {
17918        candidate.name == selected.name
17919            && candidate.kind == selected.kind
17920            && source_symbol_line(candidate) == selected_start
17921    });
17922    let target_span = stored_target
17923        .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17924        .or_else(|| symbol_hit_ast_span(&selected, &source));
17925    let target_start = target_span
17926        .as_ref()
17927        .map(|span| span.start_line)
17928        .unwrap_or(selected_start);
17929    let target_end = target_span
17930        .as_ref()
17931        .map(|span| span.end_line)
17932        .or_else(|| stored_target.and_then(source_symbol_end_line))
17933        .unwrap_or(selected_end)
17934        .max(target_start);
17935    let target_bounds = stored_target
17936        .and_then(stored_symbol_span_bounds)
17937        .or_else(|| symbol_hit_span_bounds(&selected));
17938    let target_end = stored_target
17939        .and_then(source_symbol_end_line)
17940        .unwrap_or(target_end)
17941        .max(target_start);
17942    let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17943    let line_capped_end = target_start
17944        .saturating_add(body_line_budget)
17945        .saturating_sub(1)
17946        .min(target_end)
17947        .min(total_lines.max(target_start));
17948    let token_cap = budget.body_token_cap();
17949    let (body, effective_preview_end, body_truncated) =
17950        if total_lines == 0 || target_start > total_lines {
17951            (Vec::new(), line_capped_end, false)
17952        } else {
17953            let capped = build_token_capped_preview(
17954                &all_lines,
17955                target_start,
17956                line_capped_end,
17957                max_bytes,
17958                token_cap,
17959            );
17960            (capped.preview, capped.capped_end, capped.was_capped)
17961        };
17962    let preview_end = if body_truncated {
17963        effective_preview_end
17964    } else {
17965        line_capped_end
17966    };
17967    let child_symbols = file_symbols
17968        .iter()
17969        .filter(|candidate| {
17970            if let Some((target_start_byte, target_end_byte)) = target_bounds {
17971                let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17972                else {
17973                    return false;
17974                };
17975                return candidate_start >= target_start_byte
17976                    && candidate_end <= target_end_byte
17977                    && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17978            }
17979            let line = source_symbol_line(candidate);
17980            line > target_start && line <= target_end
17981        })
17982        .take(max_items)
17983        .map(|symbol| {
17984            let line = source_symbol_line(symbol);
17985            let end_line = source_symbol_end_line(symbol);
17986            SourceSymbolRef {
17987                handle: stable_handle(
17988                    "ssym",
17989                    &format!("{}:{}:{}", file_display, symbol.name, line),
17990                ),
17991                name: truncate_for_budget(&symbol.name, max_bytes),
17992                kind: symbol.kind.clone(),
17993                language: symbol.language.clone(),
17994                file: file_display.clone(),
17995                line,
17996                end_line,
17997                signature: symbol
17998                    .signature
17999                    .clone()
18000                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
18001                span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
18002                expand: source_symbol_read_command(&root, &symbol.name, &file_display),
18003            }
18004        })
18005        .collect::<Vec<_>>();
18006    let mut warnings = Vec::new();
18007    if body_truncated {
18008        warnings.push(format!(
18009            "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
18010        ));
18011    }
18012    let summaries =
18013        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
18014    let symbol_handle = stable_handle(
18015        "sread",
18016        &format!("{}:{}:{}", file_display, selected.name, target_start),
18017    );
18018    let source_lines = preview_end
18019        .saturating_sub(target_start)
18020        .saturating_add(1)
18021        .max(1);
18022    let expand = SymbolReadExpandCommands {
18023        source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
18024        body: body_truncated.then(|| {
18025            let remaining = target_end.saturating_sub(preview_end);
18026            source_read_window_command(&root, &file_display, preview_end + 1, remaining)
18027        }),
18028        file: source_read_ast_command(&root, &file_display),
18029        explain: source_symbol_expand_command(&root, &selected.name),
18030        callers: source_symbol_graph_command(&root, &selected.name, "callers"),
18031        callees: source_symbol_graph_command(&root, &selected.name, "callees"),
18032        markdown_ast: (selected.language == "markdown").then(|| {
18033            markdown_ast_command(
18034                &root,
18035                &file_display,
18036                target_span.as_ref().map(|span| span.handle.as_str()),
18037            )
18038        }),
18039    };
18040    let report = SymbolReadReport {
18041        handle: symbol_handle.clone(),
18042        root: root.to_string_lossy().to_string(),
18043        query: symbol.to_string(),
18044        symbol: SymbolReadTarget {
18045            handle: symbol_handle,
18046            name: selected.name.clone(),
18047            kind: selected.kind.clone(),
18048            language: selected.language.clone(),
18049            file: file_display.clone(),
18050            line: target_start,
18051            end_line: Some(target_end),
18052            signature: stored_target
18053                .and_then(|stored| stored.signature.clone())
18054                .map(|signature| truncate_for_budget(&signature, max_bytes)),
18055            parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
18056            visibility: stored_target.and_then(|stored| stored.visibility.clone()),
18057            span: target_span,
18058        },
18059        range: SourceRangePreview {
18060            start: target_start,
18061            end: preview_end,
18062            total_lines,
18063            truncated_before: false,
18064            truncated_after: preview_end < target_end,
18065        },
18066        body,
18067        child_symbols,
18068        summaries,
18069        expand,
18070        warnings,
18071    };
18072
18073    if format.json_output {
18074        let truncated = report.range.truncated_after
18075            || report.body.iter().any(|line| line.text.len() >= max_bytes)
18076            || report.child_symbols.len() >= max_items;
18077        let follow_up = [
18078            Some(report.expand.source_window.clone()),
18079            report.expand.body.clone(),
18080            Some(report.expand.file.clone()),
18081            Some(report.expand.explain.clone()),
18082            Some(report.expand.callers.clone()),
18083            Some(report.expand.callees.clone()),
18084        ]
18085        .into_iter()
18086        .flatten()
18087        .chain(report.expand.markdown_ast.clone())
18088        .collect::<Vec<_>>();
18089        print_json_or_envelope(
18090            &report,
18091            &format,
18092            "symbol-read",
18093            "symbol",
18094            ToolEnvelopeSummary {
18095                text: format!(
18096                    "symbol {} {}:{}-{}",
18097                    report.symbol.name, report.symbol.file, report.range.start, report.range.end
18098                ),
18099                metrics: vec![
18100                    envelope_metric("body_lines", report.body.len()),
18101                    envelope_metric("child_symbols", report.child_symbols.len()),
18102                    envelope_metric("summaries", report.summaries.len()),
18103                ],
18104            },
18105            truncated,
18106            follow_up,
18107        )?;
18108    } else if format.compact {
18109        println!(
18110            "symbol {} {}:{}-{} handle:{} hash:{}",
18111            report.symbol.name,
18112            report.symbol.file,
18113            report.range.start,
18114            report.range.end,
18115            report.handle,
18116            content_hash
18117        );
18118        for line in &report.body {
18119            println!("{:>5} {}", line.line, line.text);
18120        }
18121        if !report.child_symbols.is_empty() {
18122            println!("children[{}]:", report.child_symbols.len());
18123            for child in &report.child_symbols {
18124                println!("  {} {}:{}", child.name, child.file, child.line);
18125            }
18126        }
18127    } else {
18128        println!(
18129            "Symbol `{}` in `{}` lines {}-{} ({})",
18130            report.symbol.name,
18131            report.symbol.file,
18132            report.range.start,
18133            report.range.end,
18134            report.handle
18135        );
18136        for line in &report.body {
18137            println!("{:>5} | {}", line.line, line.text);
18138        }
18139        if !report.child_symbols.is_empty() {
18140            println!();
18141            println!("Child symbols:");
18142            for child in &report.child_symbols {
18143                println!(
18144                    "  {} `{}` {}:{} — {}",
18145                    child.handle, child.name, child.file, child.line, child.expand
18146                );
18147            }
18148        }
18149        println!();
18150        println!("Expand:");
18151        println!("  source:  {}", report.expand.source_window);
18152        println!("  file:    {}", report.expand.file);
18153        println!("  explain: {}", report.expand.explain);
18154        println!("  callers: {}", report.expand.callers);
18155        println!("  callees: {}", report.expand.callees);
18156        for warning in &report.warnings {
18157            eprintln!("warning: {warning}");
18158        }
18159    }
18160
18161    Ok(())
18162}
18163
18164#[allow(clippy::too_many_arguments)]
18165#[derive(Serialize)]
18166struct ExplainBudgetDefinitionPreview {
18167    handle: String,
18168    #[serde(skip_serializing_if = "Option::is_none")]
18169    tag_alias: Option<String>,
18170    kind: String,
18171    name: String,
18172    file: String,
18173    line: i64,
18174    expand: String,
18175}
18176
18177#[derive(Serialize)]
18178struct ExplainBudgetEdgePreview {
18179    handle: String,
18180    #[serde(skip_serializing_if = "Option::is_none")]
18181    tag_alias: Option<String>,
18182    name: String,
18183    file: String,
18184    line: i64,
18185    expand: String,
18186}
18187
18188#[derive(Serialize)]
18189struct ExplainBudgetCommunityPreview {
18190    size: usize,
18191    members: Vec<String>,
18192}
18193
18194#[derive(Serialize)]
18195struct ExplainBudgetReport {
18196    symbol: String,
18197    max_items: usize,
18198    max_bytes: usize,
18199    definition_total: usize,
18200    callers_total: usize,
18201    callers_truncated_by_limit: bool,
18202    callees_total: usize,
18203    callees_truncated_by_limit: bool,
18204    truncated: bool,
18205    definitions: Vec<ExplainBudgetDefinitionPreview>,
18206    callers: Vec<ExplainBudgetEdgePreview>,
18207    callees: Vec<ExplainBudgetEdgePreview>,
18208    #[serde(skip_serializing_if = "Option::is_none")]
18209    community: Option<ExplainBudgetCommunityPreview>,
18210}
18211
18212#[allow(clippy::too_many_arguments)]
18213pub(crate) fn build_explain_budget_report(
18214    symbol: &str,
18215    _root: &Path,
18216    symbols: &[index::StoredSymbol],
18217    callers: &[index::StoredEdge],
18218    callers_total: usize,
18219    callers_truncated_by_limit: bool,
18220    callees: &[index::StoredEdge],
18221    callees_total: usize,
18222    callees_truncated_by_limit: bool,
18223    community: Option<&graph::Community>,
18224    budget: ResponseBudget,
18225) -> ExplainBudgetReport {
18226    let max_items = budget.preview_items();
18227    let max_bytes = budget.preview_bytes();
18228    let definitions = symbols
18229        .iter()
18230        .take(max_items)
18231        .map(|entry| {
18232            let symbol_ref = build_compact_symbol_ref(
18233                "edef",
18234                &format!(
18235                    "{}:{}:{}:{}",
18236                    entry.kind, entry.name, entry.file, entry.line
18237                ),
18238                &entry.name,
18239                entry.tags.as_deref(),
18240                max_bytes,
18241            );
18242            ExplainBudgetDefinitionPreview {
18243                handle: symbol_ref.handle,
18244                tag_alias: symbol_ref.tag_alias,
18245                kind: entry.kind.clone(),
18246                name: symbol_ref.name,
18247                file: truncate_for_budget(&entry.file, max_bytes),
18248                line: entry.line,
18249                expand: format!(
18250                    "tsift search {} --exact --path {} --limit 20",
18251                    shell_quote(&entry.name),
18252                    shell_quote(&entry.file)
18253                ),
18254            }
18255        })
18256        .collect();
18257    let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
18258        .iter()
18259        .take(max_items)
18260        .map(|entry| {
18261            let symbol_ref = build_compact_symbol_ref(
18262                "ecall",
18263                &format!(
18264                    "{}:{}:{}:{}",
18265                    entry.caller_name, entry.caller_file, entry.call_site_line, symbol
18266                ),
18267                &entry.caller_name,
18268                None,
18269                max_bytes,
18270            );
18271            ExplainBudgetEdgePreview {
18272                handle: symbol_ref.handle,
18273                tag_alias: symbol_ref.tag_alias,
18274                name: symbol_ref.name,
18275                file: truncate_for_budget(&entry.caller_file, max_bytes),
18276                line: entry.call_site_line,
18277                expand: format!(
18278                    "tsift explain {} --path {} --limit 0",
18279                    shell_quote(&entry.caller_name),
18280                    shell_quote(&entry.caller_file)
18281                ),
18282            }
18283        })
18284        .collect();
18285    let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
18286        .iter()
18287        .take(max_items)
18288        .map(|entry| {
18289            let symbol_ref = build_compact_symbol_ref(
18290                "eces",
18291                &format!(
18292                    "{}:{}:{}:{}",
18293                    entry.callee_name, entry.caller_file, entry.call_site_line, symbol
18294                ),
18295                &entry.callee_name,
18296                None,
18297                max_bytes,
18298            );
18299            ExplainBudgetEdgePreview {
18300                handle: symbol_ref.handle,
18301                tag_alias: symbol_ref.tag_alias,
18302                name: symbol_ref.name,
18303                file: truncate_for_budget(&entry.caller_file, max_bytes),
18304                line: entry.call_site_line,
18305                expand: format!(
18306                    "tsift explain {} --path {} --limit 0",
18307                    shell_quote(&entry.callee_name),
18308                    shell_quote(&entry.caller_file)
18309                ),
18310            }
18311        })
18312        .collect();
18313    let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
18314        size: entry.members.len(),
18315        members: entry
18316            .members
18317            .iter()
18318            .take(max_items)
18319            .map(|member| truncate_for_budget(&member.name, max_bytes))
18320            .collect(),
18321    });
18322
18323    ExplainBudgetReport {
18324        symbol: symbol.to_string(),
18325        max_items,
18326        max_bytes,
18327        definition_total: symbols.len(),
18328        callers_total,
18329        callers_truncated_by_limit,
18330        callees_total,
18331        callees_truncated_by_limit,
18332        truncated: symbols.len() > max_items
18333            || callers_total > callers_preview.len()
18334            || callees_total > callees_preview.len()
18335            || community
18336                .map(|entry| entry.members.len() > max_items)
18337                .unwrap_or(false),
18338        definitions,
18339        callers: callers_preview,
18340        callees: callees_preview,
18341        community: community_preview,
18342    }
18343}
18344
18345pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
18346    println!(
18347        "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
18348        shell_quote(&report.symbol),
18349        report.definitions.len(),
18350        report.definition_total,
18351        report.callers.len(),
18352        report.callers_total,
18353        report.callees.len(),
18354        report.callees_total
18355    );
18356    for entry in &report.definitions {
18357        println!(
18358            "def {} {} {}:{} expand:{}",
18359            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18360            entry.kind,
18361            entry.file,
18362            entry.line,
18363            entry.expand
18364        );
18365    }
18366    for entry in &report.callers {
18367        println!(
18368            "caller {} {}:{} expand:{}",
18369            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18370            entry.file,
18371            entry.line,
18372            entry.expand
18373        );
18374    }
18375    for entry in &report.callees {
18376        println!(
18377            "callee {} {}:{} expand:{}",
18378            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18379            entry.file,
18380            entry.line,
18381            entry.expand
18382        );
18383    }
18384    if let Some(community) = &report.community {
18385        println!(
18386            "community size:{} members:{}",
18387            community.size,
18388            community.members.join(", ")
18389        );
18390    }
18391    if report.truncated {
18392        println!(
18393            "budget truncated items:{} bytes:{}",
18394            report.max_items, report.max_bytes
18395        );
18396    }
18397}
18398
18399/// Reconcile the tsift symbol index against the tagpath `.naming/index.json`
18400/// source set and report files covered by one but not the other.
18401///
18402/// Today silent recall loss happens when tagpath's `[exclude]` / `extends`
18403/// chain or its hard-coded `SKIP_DIRS` skip files or languages that tsift
18404/// still indexes — the tsift symbols in those files cannot resolve a
18405/// `tagpath_handle` even with a fresh tagpath index. This audit surfaces
18406/// the diff so operators can decide whether to broaden the tagpath walk,
18407/// add an `[exclude]` to tsift, or accept the gap.
18408const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
18409    ".git",
18410    "node_modules",
18411    "target",
18412    "__pycache__",
18413    ".venv",
18414    "vendor",
18415];
18416
18417const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
18418    "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
18419    "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
18420    "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
18421    "sh", "bash", "zsh", "sql", "css", "tsx",
18422];
18423
18424pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
18425    let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
18426        .iter()
18427        .map(|ext| (*ext).to_string())
18428        .collect::<BTreeSet<_>>();
18429
18430    let config_path = root.join(".naming.toml");
18431    if !config_path.exists() {
18432        return extensions;
18433    }
18434
18435    match tagpath::config::resolve(&config_path) {
18436        Ok(config) => {
18437            if let Some(grammars) = config.grammars {
18438                for grammar in grammars.languages.values() {
18439                    for ext in &grammar.extensions {
18440                        if let Some(normalized) = normalize_extension(ext) {
18441                            extensions.insert(normalized);
18442                        }
18443                    }
18444                }
18445            }
18446        }
18447        Err(err) => {
18448            eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18449        }
18450    }
18451    extensions
18452}
18453
18454pub(crate) fn tagpath_audit_policy_hints(
18455    rel_path: &str,
18456    supported_extensions: &BTreeSet<String>,
18457) -> Vec<String> {
18458    let path = Path::new(rel_path);
18459    let mut hints = BTreeSet::new();
18460    if let Some(parent) = path.parent() {
18461        for component in parent.components() {
18462            if let std::path::Component::Normal(name) = component {
18463                let name = name.to_string_lossy();
18464                if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18465                    hints.insert(format!("skip_dir:{name}"));
18466                }
18467            }
18468        }
18469    }
18470    if path
18471        .extension()
18472        .and_then(|ext| ext.to_str())
18473        .and_then(normalize_extension)
18474        .is_some_and(|ext| !supported_extensions.contains(&ext))
18475    {
18476        hints.insert("extension_unsupported".to_string());
18477    }
18478    hints.into_iter().collect()
18479}
18480
18481fn normalize_extension(ext: &str) -> Option<String> {
18482    let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18483    if normalized.is_empty() {
18484        None
18485    } else {
18486        Some(normalized)
18487    }
18488}
18489
18490pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18491    match status {
18492        diff_digest::DiffDigestFileStatus::Added => "added",
18493        diff_digest::DiffDigestFileStatus::Modified => "modified",
18494        diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18495    }
18496}
18497
18498pub(crate) fn diff_digest_summary_label(
18499    state: diff_digest::DiffDigestSummaryState,
18500) -> &'static str {
18501    match state {
18502        diff_digest::DiffDigestSummaryState::Current => "current",
18503        diff_digest::DiffDigestSummaryState::Stale => "stale",
18504        diff_digest::DiffDigestSummaryState::Missing => "missing",
18505        diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18506    }
18507}
18508
18509fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18510    match state {
18511        test_digest::TestDigestSummaryState::Current => "current",
18512        test_digest::TestDigestSummaryState::Stale => "stale",
18513        test_digest::TestDigestSummaryState::Missing => "missing",
18514        test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18515    }
18516}
18517
18518fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18519    match state {
18520        log_digest::LogDigestSummaryState::Current => "current",
18521        log_digest::LogDigestSummaryState::Stale => "stale",
18522        log_digest::LogDigestSummaryState::Missing => "missing",
18523        log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18524    }
18525}
18526
18527pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18528    match mode {
18529        diff_digest::DiffDigestMode::WorkingTree => "worktree",
18530        diff_digest::DiffDigestMode::Cached => "cached",
18531        diff_digest::DiffDigestMode::Revision => "revision",
18532    }
18533}
18534
18535pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18536    match (&report.mode, &report.revision) {
18537        (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18538        (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18539        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18540            format!("revision {revision}")
18541        }
18542        (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18543    }
18544}
18545
18546pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18547    match (&report.mode, &report.revision) {
18548        (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18549        (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18550        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18551            format!("No diff found for revision {revision}.")
18552        }
18553        (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18554    }
18555}
18556
18557fn cmd_impact(
18558    path: &Path,
18559    cached: bool,
18560    revision: Option<&str>,
18561    scope: Option<&str>,
18562    limit: usize,
18563    format: OutputFormat,
18564) -> Result<()> {
18565    let report = impact::compute(
18566        path,
18567        impact::ImpactOptions {
18568            cached,
18569            revision,
18570            scope,
18571            limit,
18572        },
18573    )?;
18574    if format.json_output {
18575        println!(
18576            "{}",
18577            to_json_schema(
18578                &report,
18579                format.pretty,
18580                format.terse,
18581                format.ultra_terse,
18582                format.schema
18583            )?
18584        );
18585        return Ok(());
18586    }
18587
18588    if format.compact {
18589        println!(
18590            "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18591            diff_digest_mode_label(report.mode),
18592            report.changed_files.len(),
18593            report.changed_symbols.len(),
18594            report.affected_tests.len(),
18595            report.affected_tests_total
18596        );
18597        for target in &report.affected_tests {
18598            println!(
18599                "{} reasons:{} command:{}",
18600                target.path,
18601                target.reasons.len(),
18602                target.commands.join(" && ")
18603            );
18604        }
18605        for warning in &report.warnings {
18606            println!("warning {warning}");
18607        }
18608        return Ok(());
18609    }
18610
18611    println!("Impact ({})", diff_digest_mode_label(report.mode));
18612    println!("  changed files:          {}", report.changed_files.len());
18613    println!("  changed symbols:        {}", report.changed_symbols.len());
18614    println!(
18615        "  affected tests:         {}/{}",
18616        report.affected_tests.len(),
18617        report.affected_tests_total
18618    );
18619    for target in &report.affected_tests {
18620        println!();
18621        println!("{}", target.path);
18622        for reason in &target.reasons {
18623            println!("  - {reason}");
18624        }
18625        if !target.symbols.is_empty() {
18626            println!("  symbols: {}", target.symbols.join(", "));
18627        }
18628        for command in &target.commands {
18629            println!("  run: {}", command);
18630        }
18631    }
18632    for warning in &report.warnings {
18633        println!("warning: {warning}");
18634    }
18635    Ok(())
18636}
18637
18638pub(crate) fn render_test_digest_from_input(
18639    path: &Path,
18640    input: &str,
18641    runner: Option<&str>,
18642    format: OutputFormat,
18643) -> Result<()> {
18644    let report = test_digest::compute(path, input, runner)?;
18645    if format.json_output {
18646        println!(
18647            "{}",
18648            to_json_schema(
18649                &report,
18650                format.pretty,
18651                format.terse,
18652                format.ultra_terse,
18653                format.schema
18654            )?
18655        );
18656        return Ok(());
18657    }
18658
18659    if report.failure_groups.is_empty() {
18660        println!("No failures detected (runner: {}).", report.runner);
18661        for warning in &report.warnings {
18662            println!("warning: {warning}");
18663        }
18664        return Ok(());
18665    }
18666
18667    if format.compact {
18668        println!(
18669            "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18670            report.runner,
18671            report.failures,
18672            report.grouped_failures,
18673            report.counts.passed.unwrap_or(0),
18674            report.counts.failed.unwrap_or(report.grouped_failures),
18675            report.counts.skipped.unwrap_or(0),
18676        );
18677        for failure in &report.failure_groups {
18678            let tests = truncate_for_compact(&failure.tests.join(","), 60);
18679            let location = match (&failure.path, failure.line) {
18680                (Some(path), Some(line)) => format!("{path}:{line}"),
18681                (Some(path), None) => path.clone(),
18682                _ => "-".to_string(),
18683            };
18684            println!(
18685                "{} tests:{} count:{} summaries:{} msg:{}",
18686                location,
18687                tests,
18688                failure.occurrences,
18689                test_digest_summary_label(failure.summary_state),
18690                truncate_for_compact(&failure.message, 80)
18691            );
18692        }
18693        for warning in &report.warnings {
18694            println!("warning: {warning}");
18695        }
18696        return Ok(());
18697    }
18698
18699    println!("Test digest ({})", report.runner);
18700    println!("  failures:        {}", report.failures);
18701    println!("  failure groups:  {}", report.grouped_failures);
18702    if let Some(passed) = report.counts.passed {
18703        println!("  passed:          {}", passed);
18704    }
18705    if let Some(failed) = report.counts.failed {
18706        println!("  failed:          {}", failed);
18707    }
18708    if let Some(skipped) = report.counts.skipped {
18709        println!("  skipped:         {}", skipped);
18710    }
18711
18712    for failure in &report.failure_groups {
18713        println!();
18714        match (&failure.path, failure.line, failure.column) {
18715            (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18716            (Some(path), Some(line), None) => println!("{path}:{line}"),
18717            (Some(path), None, _) => println!("{path}"),
18718            (None, _, _) => println!("(no file anchor)"),
18719        }
18720        println!("  tests: {}", failure.tests.join(", "));
18721        println!("  occurrences: {}", failure.occurrences);
18722        println!("  message: {}", failure.message);
18723        println!(
18724            "  cached summaries: {}",
18725            test_digest_summary_label(failure.summary_state)
18726        );
18727        for summary in &failure.current_summaries {
18728            println!(
18729                "    - {}: {}",
18730                summary.symbol,
18731                truncate_for_compact(&summary.summary, 160)
18732            );
18733        }
18734    }
18735    for warning in &report.warnings {
18736        println!("warning: {warning}");
18737    }
18738    Ok(())
18739}
18740
18741#[derive(Clone, Serialize, Deserialize)]
18742struct DispatchTraceSummary {
18743    backlog: usize,
18744    job_packet: usize,
18745    worker_result: usize,
18746    worker_context: usize,
18747    source_handle: usize,
18748    semantic_rows: usize,
18749}
18750
18751#[derive(Clone, Serialize, Deserialize)]
18752struct DispatchTraceReport {
18753    contract_version: String,
18754    root: String,
18755    #[serde(skip_serializing_if = "Option::is_none")]
18756    scope: Option<String>,
18757    targets: Vec<String>,
18758    projection_freshness: GraphDbFreshnessReport,
18759    projection_hashes: Vec<String>,
18760    evidence_packet_ids: Vec<String>,
18761    shared_preparation: ConflictMatrixSharedPreparationSummary,
18762    worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18763    worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18764    summary: DispatchTraceSummary,
18765    nodes: Vec<SubstrateTerseGraphNode>,
18766    edges: Vec<SubstrateTerseGraphEdge>,
18767    conflict_matrix_decisions: Vec<String>,
18768    replay_commands: Vec<String>,
18769    repair_commands: Vec<String>,
18770    truncated: bool,
18771    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18772    warnings: Vec<String>,
18773}
18774
18775fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18776    matches!(
18777        kind,
18778        "session"
18779            | "backlog"
18780            | "job_packet"
18781            | "worker_result"
18782            | "worker_context"
18783            | "source_handle"
18784            | "semantic_concept"
18785            | "semantic_entity"
18786            | "file"
18787            | "symbol"
18788            | "route"
18789    )
18790}
18791
18792fn dispatch_trace_kind_rank(kind: &str) -> usize {
18793    match kind {
18794        "backlog" => 0,
18795        "job_packet" => 1,
18796        "worker_result" => 2,
18797        "worker_context" => 3,
18798        "source_handle" => 4,
18799        "file" => 5,
18800        "symbol" => 6,
18801        "route" => 7,
18802        "semantic_concept" => 8,
18803        "semantic_entity" => 9,
18804        "session" => 10,
18805        _ => 99,
18806    }
18807}
18808
18809fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18810    DispatchTraceSummary {
18811        backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18812        job_packet: nodes
18813            .iter()
18814            .filter(|node| node.kind == "job_packet")
18815            .count(),
18816        worker_result: nodes
18817            .iter()
18818            .filter(|node| node.kind == "worker_result")
18819            .count(),
18820        worker_context: nodes
18821            .iter()
18822            .filter(|node| node.kind == "worker_context")
18823            .count(),
18824        source_handle: nodes
18825            .iter()
18826            .filter(|node| node.kind == "source_handle")
18827            .count(),
18828        semantic_rows: nodes
18829            .iter()
18830            .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18831            .count(),
18832    }
18833}
18834
18835fn dispatch_trace_shared_preparation_summary(
18836    graph_nodes: &[SubstrateGraphNode],
18837    graph_edges: &[SubstrateGraphEdge],
18838    conflict: &ConflictMatrixReport,
18839) -> ConflictMatrixSharedPreparationSummary {
18840    ConflictMatrixSharedPreparationSummary {
18841        evidence_cache_status: conflict
18842            .inputs
18843            .shared_preparation
18844            .evidence_cache_status
18845            .clone(),
18846        graph_nodes: graph_nodes.len(),
18847        graph_edges: graph_edges.len(),
18848        evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18849        source_handles: conflict
18850            .candidates
18851            .iter()
18852            .map(|candidate| candidate.source_handles.len())
18853            .sum(),
18854        worker_context: conflict
18855            .candidates
18856            .iter()
18857            .map(|candidate| candidate.worker_context_handles.len())
18858            .sum(),
18859        worker_results: conflict
18860            .candidates
18861            .iter()
18862            .map(|candidate| candidate.worker_feedback.total)
18863            .sum(),
18864        semantic_rows: conflict
18865            .candidates
18866            .iter()
18867            .map(|candidate| candidate.semantic_related.len())
18868            .sum(),
18869        dispatch_trace_snapshot_nodes: graph_nodes.len(),
18870        dispatch_trace_snapshot_edges: graph_edges.len(),
18871    }
18872}
18873
18874fn dispatch_trace_collect_ids(
18875    targets: &[String],
18876    candidates: &[ConflictMatrixCandidate],
18877    graph_nodes: &[SubstrateGraphNode],
18878    graph_edges: &[SubstrateGraphEdge],
18879    depth: usize,
18880    limit: usize,
18881) -> (BTreeSet<String>, bool) {
18882    let target_refs = targets
18883        .iter()
18884        .map(|target| target.trim_start_matches('#').to_string())
18885        .collect::<BTreeSet<_>>();
18886    let mut ids = BTreeSet::new();
18887    for candidate in candidates {
18888        ids.insert(candidate.target_node_id.clone());
18889        for source in &candidate.source_handles {
18890            ids.insert(source.handle.clone());
18891        }
18892        for handle in &candidate.worker_context_handles {
18893            ids.insert(handle.clone());
18894        }
18895        for semantic in &candidate.semantic_related {
18896            ids.insert(semantic.handle.clone());
18897        }
18898    }
18899    for node in graph_nodes {
18900        if !dispatch_trace_allowed_node_kind(&node.kind) {
18901            continue;
18902        }
18903        if node
18904            .properties
18905            .get("ref_id")
18906            .is_some_and(|ref_id| target_refs.contains(ref_id))
18907        {
18908            ids.insert(node.id.clone());
18909        }
18910    }
18911
18912    let node_by_id = graph_nodes
18913        .iter()
18914        .map(|node| (node.id.as_str(), node))
18915        .collect::<BTreeMap<_, _>>();
18916    let max_nodes = if limit == 0 {
18917        usize::MAX
18918    } else {
18919        limit
18920            .saturating_mul(targets.len().max(1))
18921            .saturating_mul(12)
18922            .max(64)
18923    };
18924    let mut truncated = false;
18925    for _ in 0..depth.max(1) {
18926        let before = ids.len();
18927        let current_ids = ids.clone();
18928        for edge in graph_edges {
18929            if ids.len() >= max_nodes {
18930                truncated = true;
18931                break;
18932            }
18933            let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18934            if !touches {
18935                continue;
18936            }
18937            for endpoint in [&edge.from_id, &edge.to_id] {
18938                let Some(node) = node_by_id.get(endpoint.as_str()) else {
18939                    continue;
18940                };
18941                if dispatch_trace_allowed_node_kind(&node.kind) {
18942                    ids.insert(endpoint.clone());
18943                }
18944            }
18945        }
18946        if ids.len() == before || truncated {
18947            break;
18948        }
18949    }
18950    (ids, truncated)
18951}
18952
18953#[allow(clippy::too_many_arguments)]
18954fn build_dispatch_trace_report_from_conflict_snapshot(
18955    root: &Path,
18956    scope: Option<&str>,
18957    conflict: ConflictMatrixReport,
18958    graph_nodes: Vec<SubstrateGraphNode>,
18959    graph_edges: Vec<SubstrateGraphEdge>,
18960    depth: usize,
18961    limit: usize,
18962    extra_warnings: Vec<String>,
18963) -> Result<DispatchTraceReport> {
18964    let shared_preparation =
18965        dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18966    let (ids, truncated) = dispatch_trace_collect_ids(
18967        &conflict.targets,
18968        &conflict.candidates,
18969        &graph_nodes,
18970        &graph_edges,
18971        depth,
18972        limit,
18973    );
18974    let mut nodes = graph_nodes
18975        .into_iter()
18976        .filter(|node| ids.contains(&node.id))
18977        .collect::<Vec<_>>();
18978    nodes.sort_by(|left, right| {
18979        dispatch_trace_kind_rank(&left.kind)
18980            .cmp(&dispatch_trace_kind_rank(&right.kind))
18981            .then(left.id.cmp(&right.id))
18982    });
18983    let node_ids = nodes
18984        .iter()
18985        .map(|node| node.id.as_str())
18986        .collect::<BTreeSet<_>>();
18987    let mut edges = graph_edges
18988        .into_iter()
18989        .filter(|edge| {
18990            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18991        })
18992        .collect::<Vec<_>>();
18993    edges.sort_by(|left, right| {
18994        left.from_id
18995            .cmp(&right.from_id)
18996            .then(left.kind.cmp(&right.kind))
18997            .then(left.to_id.cmp(&right.to_id))
18998    });
18999    let mut warnings = conflict.warnings;
19000    warnings.extend(extra_warnings);
19001
19002    Ok(DispatchTraceReport {
19003        contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
19004        root: conflict.root,
19005        scope: conflict.scope,
19006        targets: conflict.targets,
19007        projection_freshness: conflict.orchestration.projection_freshness,
19008        projection_hashes: conflict.orchestration.projection_hashes,
19009        evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
19010        shared_preparation,
19011        worker_prompt_packets: conflict.worker_prompt_packets,
19012        worker_feedback: conflict
19013            .candidates
19014            .iter()
19015            .map(|candidate| candidate.worker_feedback.clone())
19016            .collect(),
19017        summary: dispatch_trace_summary(&nodes),
19018        nodes: nodes.into_iter().map(Into::into).collect(),
19019        edges: edges.into_iter().map(Into::into).collect(),
19020        conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
19021        replay_commands: conflict.next_commands,
19022        repair_commands: graph_db_repair_commands(root, scope),
19023        truncated,
19024        warnings,
19025    })
19026}
19027
19028fn build_dispatch_trace_report(
19029    path: &Path,
19030    scope: Option<&str>,
19031    raw_targets: &[String],
19032    depth: usize,
19033    limit: usize,
19034    impact_limit: usize,
19035) -> Result<DispatchTraceReport> {
19036    let root = lint::resolve_project_root_or_canonical_path(path)?;
19037    let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
19038    if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
19039        write_traversal_graph_store(&root, path, scope)
19040            .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19041    }
19042    let graph_db = graph_substrate_db_path(&root, scope);
19043    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19044        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19045    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19046    let extra_warnings = store
19047        .read_only_recovery()
19048        .map(graph_db_read_recovery_diagnostic)
19049        .into_iter()
19050        .collect::<Vec<_>>();
19051    let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
19052    let graph_prepared = prepare_conflict_matrix_graph_orchestration(
19053        &root,
19054        scope,
19055        "sqlite",
19056        raw_targets,
19057        &prepared,
19058        depth,
19059        limit,
19060        &store,
19061        freshness.clone(),
19062    )?;
19063    let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
19064        &prepared.preparation_cache.source_watermark,
19065        &prepared.preparation_cache.document_watermark,
19066        &prepared.preparation_cache.staged_diff_watermark,
19067        &[
19068            &format!("targets:{}", raw_targets.join(",")),
19069            &format!("depth:{depth}"),
19070            &format!("limit:{limit}"),
19071        ],
19072    );
19073    if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
19074        &root,
19075        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19076        &dt_cache_key,
19077    ) {
19078        return Ok(cached_report);
19079    }
19080    let conflict = build_conflict_matrix_report_from_prepared_graph(
19081        &root,
19082        path,
19083        scope,
19084        depth,
19085        limit,
19086        impact_limit,
19087        freshness,
19088        extra_warnings.clone(),
19089        &prepared,
19090        &graph_prepared,
19091    )?;
19092    let report = build_dispatch_trace_report_from_conflict_snapshot(
19093        &root,
19094        scope,
19095        conflict,
19096        graph_prepared.graph.nodes,
19097        graph_prepared.graph.edges,
19098        depth,
19099        limit,
19100        extra_warnings,
19101    )?;
19102    cycle_packet_cache::cycle_packet_write_cache(
19103        &root,
19104        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19105        &dt_cache_key,
19106        &report,
19107    );
19108    Ok(report)
19109}
19110
19111fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
19112    let json = serde_json::to_string(report)?.replace("</", "<\\/");
19113    let mut html = String::new();
19114    html.push_str(
19115        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
19116    );
19117    html.push_str(
19118        r#"<style>
19119:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
19120@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
19121*{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}}
19122</style>"#,
19123    );
19124    html.push_str("</head><body><div class=\"page\">");
19125    html.push_str(&format!(
19126        "<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>",
19127        html_escape(&report.targets.join(", ")),
19128        report.evidence_packet_ids.len(),
19129        report.nodes.len(),
19130        report.worker_prompt_packets.len(),
19131        html_escape(&report.contract_version)
19132    ));
19133    html.push_str(
19134        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>"#,
19135    );
19136    html.push_str("<script id=\"trace-data\" type=\"application/json\">");
19137    html.push_str(&json);
19138    html.push_str(
19139        r##"</script><script>
19140const report = JSON.parse(document.getElementById("trace-data").textContent);
19141const svg = document.getElementById("graph-canvas");
19142const nodeList = document.getElementById("nodes");
19143const packets = document.getElementById("packets");
19144const feedback = document.getElementById("feedback");
19145const nodes = report.nodes.map((node, index) => ({...node, index}));
19146const nodeById = new Map(nodes.map(node => [node.id, node]));
19147const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
19148const 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"]]);
19149function color(kind){return colorByKind.get(kind)||"#6b7280";}
19150function text(value){return value == null ? "" : String(value);}
19151function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));}
19152function layout(){
19153  const rect = svg.getBoundingClientRect();
19154  const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
19155  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
19156  const counts = new Map();
19157  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
19158  const offsets = new Map();
19159  for (const node of nodes) {
19160    const group = kinds.indexOf(node.kind);
19161    const index = offsets.get(node.kind) || 0;
19162    offsets.set(node.kind, index + 1);
19163    const total = counts.get(node.kind) || 1;
19164    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
19165    const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
19166    node.x = cx + Math.cos(angle) * ring;
19167    node.y = cy + Math.sin(angle) * ring;
19168  }
19169}
19170function draw(){
19171  svg.innerHTML = "";
19172  for (const edge of edges) {
19173    const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
19174    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
19175    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
19176    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
19177    line.setAttribute("class", "edge");
19178    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
19179    svg.appendChild(line);
19180  }
19181  for (const node of nodes) {
19182    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
19183    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
19184    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
19185    circle.setAttribute("fill", color(node.kind));
19186    circle.setAttribute("class", "node");
19187    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
19188    svg.appendChild(circle);
19189    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
19190    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
19191    label.setAttribute("class", "node-label");
19192    label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
19193    svg.appendChild(label);
19194  }
19195}
19196packets.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>";
19197feedback.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>";
19198nodeList.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("");
19199window.addEventListener("resize", () => { layout(); draw(); });
19200layout(); draw();
19201</script></div></body></html>"##,
19202    );
19203    Ok(html)
19204}
19205
19206struct DispatchTraceOptions<'a> {
19207    path: &'a Path,
19208    scope: Option<&'a str>,
19209    raw_targets: &'a [String],
19210    depth: usize,
19211    limit: usize,
19212    impact_limit: usize,
19213    trace_format: DispatchTraceFormat,
19214}
19215
19216fn cmd_dispatch_trace(
19217    options: DispatchTraceOptions<'_>,
19218    output_format: OutputFormat,
19219) -> Result<()> {
19220    let report = build_dispatch_trace_report(
19221        options.path,
19222        options.scope,
19223        options.raw_targets,
19224        options.depth,
19225        options.limit,
19226        options.impact_limit,
19227    )?;
19228    match options.trace_format {
19229        DispatchTraceFormat::Json => {
19230            if output_format.envelope {
19231                print_json_or_envelope(
19232                    &report,
19233                    &output_format,
19234                    "dispatch-trace",
19235                    "operator-review",
19236                    ToolEnvelopeSummary {
19237                        text: format!(
19238                            "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
19239                            report.targets.len(),
19240                            report.nodes.len(),
19241                            report.worker_prompt_packets.len()
19242                        ),
19243                        metrics: vec![
19244                            envelope_metric("targets", report.targets.len()),
19245                            envelope_metric("nodes", report.nodes.len()),
19246                            envelope_metric("edges", report.edges.len()),
19247                            envelope_metric(
19248                                "worker_prompt_packets",
19249                                report.worker_prompt_packets.len(),
19250                            ),
19251                        ],
19252                    },
19253                    report.truncated,
19254                    report.replay_commands.clone(),
19255                )
19256            } else {
19257                println!(
19258                    "{}",
19259                    to_json_schema(
19260                        &report,
19261                        output_format.pretty,
19262                        output_format.terse,
19263                        output_format.ultra_terse,
19264                        output_format.schema
19265                    )?
19266                );
19267                Ok(())
19268            }
19269        }
19270        DispatchTraceFormat::Html => {
19271            println!("{}", dispatch_trace_html(&report)?);
19272            Ok(())
19273        }
19274    }
19275}
19276
19277#[derive(Clone, Debug)]
19278struct DependencyDagProfile {
19279    id: String,
19280    graph_node_id: String,
19281    label: String,
19282    path: Option<String>,
19283    line: Option<i64>,
19284    detail: Option<String>,
19285    source_files: BTreeSet<String>,
19286    source_symbols: BTreeSet<String>,
19287    config_files: BTreeSet<String>,
19288    expected_tests: BTreeSet<String>,
19289    semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
19290    worker_feedback: ConflictMatrixWorkerFeedback,
19291}
19292
19293#[derive(Clone, Debug, Serialize)]
19294struct DependencyDagNode {
19295    id: String,
19296    graph_node_id: String,
19297    label: String,
19298    #[serde(skip_serializing_if = "Option::is_none")]
19299    path: Option<String>,
19300    #[serde(skip_serializing_if = "Option::is_none")]
19301    line: Option<i64>,
19302    #[serde(skip_serializing_if = "Option::is_none")]
19303    detail: Option<String>,
19304    source_files: Vec<String>,
19305    source_symbols: Vec<String>,
19306    config_files: Vec<String>,
19307    expected_tests: Vec<String>,
19308    semantic_refs: Vec<ConflictMatrixSemanticRef>,
19309    worker_feedback: ConflictMatrixWorkerFeedback,
19310}
19311
19312#[derive(Clone, Debug, Serialize)]
19313struct DependencyDagEdge {
19314    from: String,
19315    to: String,
19316    kind: String,
19317    weight: usize,
19318    reasons: Vec<String>,
19319    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19320    shared_files: Vec<String>,
19321    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19322    shared_symbols: Vec<String>,
19323    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19324    shared_tests: Vec<String>,
19325    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19326    shared_config_files: Vec<String>,
19327    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19328    shared_semantic_refs: Vec<String>,
19329}
19330
19331#[derive(Clone, Debug, Serialize)]
19332struct DependencyDagTopoBatch {
19333    batch: usize,
19334    targets: Vec<String>,
19335}
19336
19337#[derive(Clone, Debug, Serialize)]
19338struct DependencyDagCycleDiagnostics {
19339    has_cycles: bool,
19340    blocked_nodes: Vec<String>,
19341    cycle_edges: Vec<DependencyDagEdge>,
19342}
19343
19344#[derive(Serialize)]
19345struct DependencyDagSummary {
19346    nodes: usize,
19347    edges: usize,
19348    topo_batches: usize,
19349    has_cycles: bool,
19350}
19351
19352#[derive(Serialize)]
19353struct DependencyDagReport {
19354    contract_version: &'static str,
19355    root: String,
19356    #[serde(skip_serializing_if = "Option::is_none")]
19357    scope: Option<String>,
19358    path: String,
19359    targets: Vec<String>,
19360    projection_freshness: GraphDbFreshnessReport,
19361    projection_hashes: Vec<String>,
19362    nodes: Vec<DependencyDagNode>,
19363    edges: Vec<DependencyDagEdge>,
19364    topo_batches: Vec<DependencyDagTopoBatch>,
19365    cycle_diagnostics: DependencyDagCycleDiagnostics,
19366    summary: DependencyDagSummary,
19367    replay_commands: Vec<String>,
19368    repair_commands: Vec<String>,
19369    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19370    warnings: Vec<String>,
19371}
19372
19373fn dependency_dag_backlog_node_for_target(
19374    store: &impl GraphStore,
19375    target: &str,
19376) -> Result<SubstrateGraphNode> {
19377    let resolved = graph_db_resolve_evidence_target(store, target)?
19378        .with_context(|| format!("dependency-dag target not found: {target}"))?;
19379    if resolved.kind == "backlog" {
19380        return Ok(resolved);
19381    }
19382    let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
19383        bail!(
19384            "dependency-dag target {} resolved to {} without a backlog ref_id",
19385            target,
19386            resolved.kind
19387        );
19388    };
19389    store
19390        .nodes_by_kind("backlog")?
19391        .into_iter()
19392        .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
19393        .min_by(|left, right| {
19394            left.properties
19395                .get("line")
19396                .and_then(|value| value.parse::<i64>().ok())
19397                .cmp(
19398                    &right
19399                        .properties
19400                        .get("line")
19401                        .and_then(|value| value.parse::<i64>().ok()),
19402                )
19403                .then(left.id.cmp(&right.id))
19404        })
19405        .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
19406}
19407
19408fn dependency_dag_resolve_backlog_nodes(
19409    root: &Path,
19410    path: &Path,
19411    store: &impl GraphStore,
19412    raw_targets: &[String],
19413) -> Result<Vec<SubstrateGraphNode>> {
19414    let mut nodes = Vec::new();
19415    let mut seen = BTreeSet::new();
19416    if raw_targets.is_empty() {
19417        let hinted_path = if path.is_absolute() {
19418            path.to_path_buf()
19419        } else {
19420            root.join(path)
19421        };
19422        let hinted_markdown = hinted_path
19423            .extension()
19424            .and_then(|ext| ext.to_str())
19425            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
19426        let hinted_rel = hinted_markdown.then(|| {
19427            relativize_pathbuf(&hinted_path, root)
19428                .to_string_lossy()
19429                .replace('\\', "/")
19430        });
19431        for node in store.nodes_by_kind("backlog")? {
19432            if let Some(expected_path) = &hinted_rel
19433                && node.properties.get("path") != Some(expected_path)
19434            {
19435                continue;
19436            }
19437            if seen.insert(node.id.clone()) {
19438                nodes.push(node);
19439            }
19440        }
19441        if nodes.is_empty() && hinted_rel.is_some() {
19442            for node in store.nodes_by_kind("backlog")? {
19443                if seen.insert(node.id.clone()) {
19444                    nodes.push(node);
19445                }
19446            }
19447        }
19448    } else {
19449        for target in raw_targets {
19450            let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19451            let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19452            if seen.insert(node.id.clone()) {
19453                nodes.push(node);
19454            }
19455        }
19456    }
19457    if nodes.is_empty() {
19458        bail!("dependency-dag needs at least one resolvable backlog id");
19459    }
19460    nodes.sort_by(|left, right| {
19461        left.properties
19462            .get("line")
19463            .and_then(|value| value.parse::<i64>().ok())
19464            .cmp(
19465                &right
19466                    .properties
19467                    .get("line")
19468                    .and_then(|value| value.parse::<i64>().ok()),
19469            )
19470            .then(left.id.cmp(&right.id))
19471    });
19472    Ok(nodes)
19473}
19474
19475fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19476    node.properties
19477        .get("ref_id")
19478        .cloned()
19479        .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19480}
19481
19482fn dependency_dag_node_profile(
19483    root: &Path,
19484    store: &impl GraphStore,
19485    node: &SubstrateGraphNode,
19486    graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19487    graph_edges: &[SubstrateGraphEdge],
19488    depth: usize,
19489    limit: usize,
19490) -> Result<DependencyDagProfile> {
19491    let id = dependency_dag_node_id(node);
19492    let mut source_files = BTreeSet::new();
19493    let mut source_symbols = BTreeSet::new();
19494    for edge in graph_edges
19495        .iter()
19496        .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19497    {
19498        let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19499            continue;
19500        };
19501        match target.kind.as_str() {
19502            "file" | "route" => {
19503                if let Some(path) = target.properties.get("path") {
19504                    source_files.insert(path.clone());
19505                }
19506            }
19507            "symbol" => {
19508                source_symbols.insert(target.label.clone());
19509                if let Some(path) = target.properties.get("path") {
19510                    source_files.insert(path.clone());
19511                }
19512            }
19513            _ => {}
19514        }
19515    }
19516
19517    let max_rows = if limit == 0 { usize::MAX } else { limit };
19518    for (source, _) in
19519        graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19520    {
19521        let terse: SubstrateTerseGraphNode = (&source).into();
19522        if let Some(handle) = conflict_matrix_source_handle(&terse) {
19523            source_files.insert(handle.file);
19524        }
19525    }
19526
19527    let worker_results = graph_nodes_by_id
19528        .values()
19529        .filter(|candidate| {
19530            candidate.kind == "worker_result"
19531                && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19532        })
19533        .map(SubstrateTerseGraphNode::from)
19534        .collect::<Vec<_>>();
19535    let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19536    let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19537    let config_files = source_files
19538        .iter()
19539        .filter(|file| is_planner_config_path(file))
19540        .cloned()
19541        .collect();
19542
19543    let mut semantic_refs = BTreeMap::new();
19544    for kind in ["semantic_concept", "semantic_entity"] {
19545        for (semantic, _) in
19546            graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19547        {
19548            let terse: SubstrateTerseGraphNode = (&semantic).into();
19549            let item = conflict_matrix_semantic_ref(root, &terse);
19550            semantic_refs
19551                .entry(format!("{}:{}", item.kind, item.label))
19552                .or_insert(item);
19553        }
19554    }
19555
19556    Ok(DependencyDagProfile {
19557        id,
19558        graph_node_id: node.id.clone(),
19559        label: node.label.clone(),
19560        path: node.properties.get("path").cloned(),
19561        line: node
19562            .properties
19563            .get("line")
19564            .and_then(|value| value.parse::<i64>().ok()),
19565        detail: node.properties.get("detail").cloned(),
19566        source_files,
19567        source_symbols,
19568        config_files,
19569        expected_tests,
19570        semantic_refs,
19571        worker_feedback,
19572    })
19573}
19574
19575fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19576    let lower = text.to_ascii_lowercase();
19577    let mut refs = Vec::new();
19578    for marker in markers {
19579        let mut offset = 0usize;
19580        while let Some(pos) = lower[offset..].find(marker) {
19581            let start = offset + pos + marker.len();
19582            let segment = text[start..]
19583                .split(['\n', '.'])
19584                .next()
19585                .unwrap_or(&text[start..]);
19586            refs.extend(extract_conflict_target_refs(segment));
19587            offset = start;
19588        }
19589    }
19590    dedupe_preserve_order(refs)
19591}
19592
19593fn dependency_dag_push_edge(
19594    edges: &mut Vec<DependencyDagEdge>,
19595    seen: &mut BTreeSet<(String, String, String)>,
19596    edge: DependencyDagEdge,
19597) {
19598    if edge.from == edge.to {
19599        return;
19600    }
19601    if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19602        edges.push(edge);
19603    }
19604}
19605
19606fn dependency_dag_explicit_edges(
19607    profiles: &[DependencyDagProfile],
19608    target_ids: &BTreeSet<String>,
19609    edges: &mut Vec<DependencyDagEdge>,
19610    seen: &mut BTreeSet<(String, String, String)>,
19611) {
19612    for profile in profiles {
19613        let detail = profile.detail.as_deref().unwrap_or_default();
19614        for dep in dependency_dag_marker_refs(
19615            detail,
19616            &[
19617                "depends on",
19618                "depends-on",
19619                "deps:",
19620                "after",
19621                "blocked by",
19622                "requires",
19623            ],
19624        ) {
19625            if target_ids.contains(&dep) {
19626                dependency_dag_push_edge(
19627                    edges,
19628                    seen,
19629                    DependencyDagEdge {
19630                        from: dep.clone(),
19631                        to: profile.id.clone(),
19632                        kind: "explicit_depends_on".to_string(),
19633                        weight: 1000,
19634                        reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19635                        shared_files: Vec::new(),
19636                        shared_symbols: Vec::new(),
19637                        shared_tests: Vec::new(),
19638                        shared_config_files: Vec::new(),
19639                        shared_semantic_refs: Vec::new(),
19640                    },
19641                );
19642            }
19643        }
19644        for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19645            if target_ids.contains(&downstream) {
19646                dependency_dag_push_edge(
19647                    edges,
19648                    seen,
19649                    DependencyDagEdge {
19650                        from: profile.id.clone(),
19651                        to: downstream.clone(),
19652                        kind: "explicit_before".to_string(),
19653                        weight: 900,
19654                        reasons: vec![format!(
19655                            "{} declares it should run before #{downstream}",
19656                            profile.id
19657                        )],
19658                        shared_files: Vec::new(),
19659                        shared_symbols: Vec::new(),
19660                        shared_tests: Vec::new(),
19661                        shared_config_files: Vec::new(),
19662                        shared_semantic_refs: Vec::new(),
19663                    },
19664                );
19665            }
19666        }
19667    }
19668}
19669
19670fn dependency_dag_worker_follow_up_edges(
19671    profiles: &[DependencyDagProfile],
19672    target_ids: &BTreeSet<String>,
19673    edges: &mut Vec<DependencyDagEdge>,
19674    seen: &mut BTreeSet<(String, String, String)>,
19675) {
19676    for profile in profiles {
19677        for follow_up in &profile.worker_feedback.follow_up_ids {
19678            if target_ids.contains(follow_up) {
19679                dependency_dag_push_edge(
19680                    edges,
19681                    seen,
19682                    DependencyDagEdge {
19683                        from: profile.id.clone(),
19684                        to: follow_up.clone(),
19685                        kind: "worker_result_follow_up".to_string(),
19686                        weight: 700,
19687                        reasons: vec![format!(
19688                            "worker_result for #{} references follow-up #{}",
19689                            profile.id, follow_up
19690                        )],
19691                        shared_files: Vec::new(),
19692                        shared_symbols: Vec::new(),
19693                        shared_tests: Vec::new(),
19694                        shared_config_files: Vec::new(),
19695                        shared_semantic_refs: Vec::new(),
19696                    },
19697                );
19698            }
19699        }
19700    }
19701}
19702
19703fn dependency_dag_overlap_edges(
19704    profiles: &[DependencyDagProfile],
19705    edges: &mut Vec<DependencyDagEdge>,
19706    seen: &mut BTreeSet<(String, String, String)>,
19707) {
19708    for left_idx in 0..profiles.len() {
19709        for right_idx in (left_idx + 1)..profiles.len() {
19710            let left = &profiles[left_idx];
19711            let right = &profiles[right_idx];
19712            let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19713            let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19714            let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19715            let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19716            let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19717            let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19718            let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19719            if shared_files.is_empty()
19720                && shared_symbols.is_empty()
19721                && shared_tests.is_empty()
19722                && shared_config_files.is_empty()
19723                && shared_semantic_refs.is_empty()
19724            {
19725                continue;
19726            }
19727            let kind = if shared_files.is_empty()
19728                && shared_symbols.is_empty()
19729                && shared_tests.is_empty()
19730                && shared_config_files.is_empty()
19731            {
19732                "semantic_relation"
19733            } else {
19734                "shared_resource"
19735            };
19736            let mut reasons = Vec::new();
19737            if !shared_files.is_empty() {
19738                reasons.push(format!("shared files: {}", shared_files.join(", ")));
19739            }
19740            if !shared_symbols.is_empty() {
19741                reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19742            }
19743            if !shared_tests.is_empty() {
19744                reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19745            }
19746            if !shared_config_files.is_empty() {
19747                reasons.push(format!(
19748                    "shared config files: {}",
19749                    shared_config_files.join(", ")
19750                ));
19751            }
19752            if !shared_semantic_refs.is_empty() {
19753                reasons.push(format!(
19754                    "shared semantic refs: {}",
19755                    shared_semantic_refs.join(", ")
19756                ));
19757            }
19758            let weight = shared_files.len() * 100
19759                + shared_config_files.len() * 100
19760                + shared_symbols.len() * 40
19761                + shared_tests.len() * 10
19762                + shared_semantic_refs.len() * 5;
19763            dependency_dag_push_edge(
19764                edges,
19765                seen,
19766                DependencyDagEdge {
19767                    from: left.id.clone(),
19768                    to: right.id.clone(),
19769                    kind: kind.to_string(),
19770                    weight,
19771                    reasons,
19772                    shared_files,
19773                    shared_symbols,
19774                    shared_tests,
19775                    shared_config_files,
19776                    shared_semantic_refs,
19777                },
19778            );
19779        }
19780    }
19781}
19782
19783fn dependency_dag_topo_batches(
19784    targets: &[String],
19785    edges: &[DependencyDagEdge],
19786) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19787    let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19788    let order = targets
19789        .iter()
19790        .enumerate()
19791        .map(|(idx, id)| (id.clone(), idx))
19792        .collect::<BTreeMap<_, _>>();
19793    let mut indegree = targets
19794        .iter()
19795        .map(|id| (id.clone(), 0usize))
19796        .collect::<BTreeMap<_, _>>();
19797    let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19798    let mut seen_pairs = BTreeSet::<(String, String)>::new();
19799    for edge in edges {
19800        if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19801            continue;
19802        }
19803        if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19804            continue;
19805        }
19806        *indegree.entry(edge.to.clone()).or_default() += 1;
19807        outgoing
19808            .entry(edge.from.clone())
19809            .or_default()
19810            .push(edge.to.clone());
19811    }
19812    for values in outgoing.values_mut() {
19813        values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19814        values.dedup();
19815    }
19816
19817    let mut processed = BTreeSet::new();
19818    let mut batches = Vec::new();
19819    loop {
19820        let mut ready = targets
19821            .iter()
19822            .filter(|id| !processed.contains(*id))
19823            .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19824            .cloned()
19825            .collect::<Vec<_>>();
19826        ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19827        if ready.is_empty() {
19828            break;
19829        }
19830        for id in &ready {
19831            processed.insert(id.clone());
19832            for next in outgoing.get(id).into_iter().flatten() {
19833                if let Some(value) = indegree.get_mut(next) {
19834                    *value = value.saturating_sub(1);
19835                }
19836            }
19837        }
19838        batches.push(DependencyDagTopoBatch {
19839            batch: batches.len() + 1,
19840            targets: ready,
19841        });
19842    }
19843
19844    let blocked_nodes = targets
19845        .iter()
19846        .filter(|id| !processed.contains(*id))
19847        .cloned()
19848        .collect::<Vec<_>>();
19849    let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19850    let cycle_edges = edges
19851        .iter()
19852        .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19853        .cloned()
19854        .collect::<Vec<_>>();
19855    (
19856        batches,
19857        DependencyDagCycleDiagnostics {
19858            has_cycles: !blocked_nodes.is_empty(),
19859            blocked_nodes,
19860            cycle_edges,
19861        },
19862    )
19863}
19864
19865fn dependency_dag_replay_commands(
19866    path: &Path,
19867    scope: Option<&str>,
19868    targets: &[String],
19869    depth: usize,
19870    limit: usize,
19871) -> Vec<String> {
19872    let target_args = targets
19873        .iter()
19874        .map(|target| shell_quote(target))
19875        .collect::<Vec<_>>()
19876        .join(" ");
19877    let mut command = format!(
19878        "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19879        shell_quote(path.to_string_lossy().as_ref()),
19880        scope
19881            .map(|scope| format!(" --scope {}", shell_quote(scope)))
19882            .unwrap_or_default(),
19883        depth,
19884        limit
19885    );
19886    if !target_args.is_empty() {
19887        command.push(' ');
19888        command.push_str(&target_args);
19889    }
19890    vec![command]
19891}
19892
19893fn build_dependency_dag_report(
19894    path: &Path,
19895    scope: Option<&str>,
19896    raw_targets: &[String],
19897    depth: usize,
19898    limit: usize,
19899) -> Result<DependencyDagReport> {
19900    let root = lint::resolve_project_root_or_canonical_path(path)?;
19901    write_traversal_graph_store(&root, path, scope)
19902        .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19903    let graph_db = graph_substrate_db_path(&root, scope);
19904    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19905        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19906    let mut warnings = Vec::new();
19907    if let Some(recovery) = store.read_only_recovery() {
19908        warnings.push(graph_db_read_recovery_diagnostic(recovery));
19909    }
19910    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19911    if freshness.fail_closed {
19912        bail!(
19913            "dependency-dag graph projection failed closed: {}; repair: {}",
19914            freshness.diagnostics.join("; "),
19915            graph_db_repair_commands(&root, scope).join("; ")
19916        );
19917    }
19918
19919    let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19920    let graph_nodes = store.all_nodes()?;
19921    let graph_edges = store.all_edges()?;
19922    let graph_nodes_by_id = graph_nodes
19923        .into_iter()
19924        .map(|node| (node.id.clone(), node))
19925        .collect::<BTreeMap<_, _>>();
19926    let profiles = target_nodes
19927        .iter()
19928        .map(|node| {
19929            dependency_dag_node_profile(
19930                &root,
19931                &store,
19932                node,
19933                &graph_nodes_by_id,
19934                &graph_edges,
19935                depth,
19936                limit,
19937            )
19938        })
19939        .collect::<Result<Vec<_>>>()?;
19940    let targets = profiles
19941        .iter()
19942        .map(|profile| profile.id.clone())
19943        .collect::<Vec<_>>();
19944    let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19945
19946    let mut edges = Vec::new();
19947    let mut seen_edges = BTreeSet::new();
19948    dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19949    dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19950    dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19951    edges.sort_by(|left, right| {
19952        left.from
19953            .cmp(&right.from)
19954            .then(left.to.cmp(&right.to))
19955            .then(left.kind.cmp(&right.kind))
19956    });
19957    let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19958
19959    let nodes = profiles
19960        .into_iter()
19961        .map(|profile| DependencyDagNode {
19962            id: profile.id,
19963            graph_node_id: profile.graph_node_id,
19964            label: profile.label,
19965            path: profile.path,
19966            line: profile.line,
19967            detail: profile.detail,
19968            source_files: sorted_set(&profile.source_files),
19969            source_symbols: sorted_set(&profile.source_symbols),
19970            config_files: sorted_set(&profile.config_files),
19971            expected_tests: sorted_set(&profile.expected_tests),
19972            semantic_refs: profile.semantic_refs.into_values().collect(),
19973            worker_feedback: profile.worker_feedback,
19974        })
19975        .collect::<Vec<_>>();
19976    let projection_hashes = freshness
19977        .content_hash
19978        .clone()
19979        .into_iter()
19980        .collect::<Vec<_>>();
19981    let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19982    let repair_commands = graph_db_repair_commands(&root, scope);
19983    let summary = DependencyDagSummary {
19984        nodes: nodes.len(),
19985        edges: edges.len(),
19986        topo_batches: topo_batches.len(),
19987        has_cycles: cycle_diagnostics.has_cycles,
19988    };
19989
19990    Ok(DependencyDagReport {
19991        contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19992        root: root.to_string_lossy().to_string(),
19993        scope: scope.map(str::to_string),
19994        path: path.to_string_lossy().to_string(),
19995        targets,
19996        projection_freshness: freshness,
19997        projection_hashes,
19998        nodes,
19999        edges,
20000        topo_batches,
20001        cycle_diagnostics,
20002        summary,
20003        replay_commands,
20004        repair_commands,
20005        warnings,
20006    })
20007}
20008
20009fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
20010    if compact {
20011        println!(
20012            "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
20013            report.targets.len(),
20014            report.edges.len(),
20015            report.topo_batches.len(),
20016            report.cycle_diagnostics.has_cycles
20017        );
20018    } else {
20019        println!("Dependency DAG");
20020        println!("  targets: {}", report.targets.join(", "));
20021        println!("  edges:   {}", report.edges.len());
20022        println!("  cycles:  {}", report.cycle_diagnostics.has_cycles);
20023    }
20024    for batch in &report.topo_batches {
20025        println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
20026    }
20027    for edge in &report.edges {
20028        println!(
20029            "edge {} -> {} kind:{} weight:{}",
20030            edge.from, edge.to, edge.kind, edge.weight
20031        );
20032        for reason in &edge.reasons {
20033            println!("  reason: {reason}");
20034        }
20035    }
20036    if report.cycle_diagnostics.has_cycles {
20037        println!(
20038            "cycle blocked nodes: {}",
20039            report.cycle_diagnostics.blocked_nodes.join(", ")
20040        );
20041    }
20042    for command in &report.replay_commands {
20043        println!("replay: {command}");
20044    }
20045    for command in &report.repair_commands {
20046        println!("repair: {command}");
20047    }
20048    for warning in &report.warnings {
20049        println!("warning: {warning}");
20050    }
20051}
20052
20053fn cmd_dependency_dag(
20054    path: &Path,
20055    scope: Option<&str>,
20056    raw_targets: &[String],
20057    depth: usize,
20058    limit: usize,
20059    format: OutputFormat,
20060) -> Result<()> {
20061    let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
20062    if format.json_output {
20063        print_json_or_envelope(
20064            &report,
20065            &format,
20066            "dependency-dag",
20067            "topological-planning",
20068            ToolEnvelopeSummary {
20069                text: format!(
20070                    "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
20071                    report.targets.len(),
20072                    report.edges.len(),
20073                    report.topo_batches.len(),
20074                    report.cycle_diagnostics.has_cycles
20075                ),
20076                metrics: vec![
20077                    envelope_metric("targets", report.targets.len()),
20078                    envelope_metric("edges", report.edges.len()),
20079                    envelope_metric("topo_batches", report.topo_batches.len()),
20080                    envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
20081                ],
20082            },
20083            report.cycle_diagnostics.has_cycles,
20084            report.replay_commands.clone(),
20085        )
20086    } else {
20087        print_dependency_dag_human(&report, format.compact);
20088        Ok(())
20089    }
20090}
20091
20092/// Persist a bulky raw log behind an artifact handle and attach it to the
20093/// report, so the bounded digest references the full transcript via a stable
20094/// handle + expansion command instead of losing it (stdin) or relying on
20095/// inlined groups. No-op for small logs or when the artifacts dir is unwritable.
20096fn maybe_attach_log_digest_raw_artifact(
20097    root: &Path,
20098    report: &mut log_digest::LogDigestReport,
20099    input: &str,
20100) -> Result<()> {
20101    if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
20102        return Ok(());
20103    }
20104    let key = format!("logdigest:{}:{}", report.total_lines, input.len());
20105    let artifact_path = root
20106        .join(".tsift/artifacts")
20107        .join(format!("{}.log", stable_handle("logdg", &key)));
20108    let expand = format!(
20109        "tsift log-digest --path {} --input {} --json",
20110        shell_quote(root.to_string_lossy().as_ref()),
20111        shell_quote(artifact_path.to_string_lossy().as_ref())
20112    );
20113    let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
20114    report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
20115        handle: artifact.handle,
20116        path: artifact.path,
20117        bytes: artifact.bytes,
20118        lines: artifact.lines,
20119        expand: artifact.expand,
20120    });
20121    Ok(())
20122}
20123
20124/// Run the log-digest token-savings + false-negative fixture gate: prove the
20125/// digest both compresses raw cargo/pytest/npm/pnpm/agent-doc logs and preserves
20126/// their real signals. With `fail_under`, exits non-zero on any case miss.
20127pub(crate) fn render_log_digest_fixture(
20128    path: &Path,
20129    fixture_path: &Path,
20130    fail_under: bool,
20131    format: OutputFormat,
20132) -> Result<()> {
20133    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20134    let fixture_body = fs::read_to_string(fixture_path)
20135        .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
20136    let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
20137        .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
20138    let report = log_digest::evaluate_fixture(&root, &fixture)?;
20139
20140    if format.json_output {
20141        print_json_or_envelope(
20142            &report,
20143            &format,
20144            "log-digest-fixture",
20145            "report",
20146            ToolEnvelopeSummary {
20147                text: if report.passed {
20148                    format!("log-digest gate passed for {} case(s)", report.total_cases)
20149                } else {
20150                    format!("log-digest gate failed {} case(s)", report.failed_cases)
20151                },
20152                metrics: vec![
20153                    envelope_metric("cases", report.total_cases),
20154                    envelope_metric("failed", report.failed_cases),
20155                    envelope_metric("passed", report.passed),
20156                ],
20157            },
20158            false,
20159            vec![],
20160        )?;
20161    } else {
20162        println!("Log digest fixture gate");
20163        println!("  cases:  {}", report.total_cases);
20164        println!("  failed: {}", report.failed_cases);
20165        println!("  status: {}", if report.passed { "pass" } else { "fail" });
20166        for case in &report.cases {
20167            println!(
20168                "  [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
20169                if case.passed { "pass" } else { "FAIL" },
20170                case.name,
20171                case.ecosystem,
20172                case.savings_percent,
20173                case.minimum_savings_percent,
20174                case.raw_tokens,
20175                case.digest_tokens
20176            );
20177            if !case.missing_required_signals.is_empty() {
20178                println!(
20179                    "    missing required signals: {}",
20180                    case.missing_required_signals.join(", ")
20181                );
20182            }
20183            if !case.present_forbidden_signals.is_empty() {
20184                println!(
20185                    "    present forbidden signals: {}",
20186                    case.present_forbidden_signals.join(", ")
20187                );
20188            }
20189        }
20190    }
20191
20192    if fail_under && !report.passed {
20193        bail!("log-digest fixture gate failed");
20194    }
20195    Ok(())
20196}
20197
20198pub(crate) fn render_log_digest_from_input(
20199    path: &Path,
20200    input: &str,
20201    format: OutputFormat,
20202) -> Result<()> {
20203    let mut report = log_digest::compute(path, input)?;
20204    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20205    maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
20206    if format.json_output {
20207        println!(
20208            "{}",
20209            to_json_schema(
20210                &report,
20211                format.pretty,
20212                format.terse,
20213                format.ultra_terse,
20214                format.schema
20215            )?
20216        );
20217        return Ok(());
20218    }
20219
20220    if format.compact {
20221        println!(
20222            "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
20223            report.non_empty_lines,
20224            report.signal_groups,
20225            report.repeated_line_groups,
20226            report.file_ref_groups,
20227            report.symbol_ref_groups,
20228            report.stack_groups
20229        );
20230        for signal in &report.signals {
20231            let location = match (&signal.path, signal.line) {
20232                (Some(path), Some(line)) => format!("{path}:{line}"),
20233                (Some(path), None) => path.clone(),
20234                _ => "-".to_string(),
20235            };
20236            println!(
20237                "{} sev:{} count:{} sums:{} msg:{}",
20238                location,
20239                signal.severity,
20240                signal.occurrences,
20241                log_digest_summary_label(signal.summary_state),
20242                truncate_for_compact(&signal.message, 80)
20243            );
20244        }
20245        for repeated in &report.repeated_lines {
20246            println!(
20247                "repeat count:{} line:{}",
20248                repeated.occurrences,
20249                truncate_for_compact(&repeated.line, 80)
20250            );
20251        }
20252        for family in &report.line_families {
20253            println!(
20254                "family count:{} variants:{} template:{}",
20255                family.occurrences,
20256                family.variants,
20257                truncate_for_compact(&family.template, 80)
20258            );
20259        }
20260        for symbol in &report.symbol_refs {
20261            println!(
20262                "sym:{} count:{} sums:{}",
20263                symbol.symbol,
20264                symbol.occurrences,
20265                log_digest_summary_label(symbol.summary_state)
20266            );
20267        }
20268        if let Some(artifact) = &report.raw_log_artifact {
20269            println!(
20270                "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
20271                artifact.handle, artifact.lines, artifact.bytes, artifact.expand
20272            );
20273        }
20274        for warning in &report.warnings {
20275            println!("warning: {warning}");
20276        }
20277        return Ok(());
20278    }
20279
20280    println!("Log digest");
20281    println!("  lines:                    {}", report.total_lines);
20282    println!("  non-empty lines:          {}", report.non_empty_lines);
20283    println!("  signal groups:            {}", report.signal_groups);
20284    println!(
20285        "  repeated lines:           {}",
20286        report.repeated_line_groups
20287    );
20288    println!(
20289        "  repeated line instances:  {}",
20290        report.repeated_line_occurrences
20291    );
20292    println!("  line families:            {}", report.line_family_groups);
20293    println!("  file refs:                {}", report.file_ref_groups);
20294    println!("  symbol refs:              {}", report.symbol_ref_groups);
20295    println!("  stack groups:             {}", report.stack_groups);
20296
20297    if !report.signals.is_empty() {
20298        println!();
20299        println!("Signals:");
20300        for signal in &report.signals {
20301            match (&signal.path, signal.line, signal.column) {
20302                (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
20303                (Some(path), Some(line), None) => println!("{path}:{line}"),
20304                (Some(path), None, _) => println!("{path}"),
20305                (None, _, _) => println!("(no file anchor)"),
20306            }
20307            println!("  severity: {}", signal.severity);
20308            println!("  occurrences: {}", signal.occurrences);
20309            println!("  message: {}", signal.message);
20310            println!(
20311                "  cached summaries: {}",
20312                log_digest_summary_label(signal.summary_state)
20313            );
20314            for summary in &signal.current_summaries {
20315                println!(
20316                    "    - {}: {}",
20317                    summary.symbol,
20318                    truncate_for_compact(&summary.summary, 160)
20319                );
20320            }
20321        }
20322    }
20323
20324    if !report.repeated_lines.is_empty() {
20325        println!();
20326        println!("Repeated lines:");
20327        for repeated in &report.repeated_lines {
20328            println!(
20329                "  {}x {}",
20330                repeated.occurrences,
20331                truncate_for_compact(&repeated.line, 180)
20332            );
20333        }
20334    }
20335
20336    if !report.line_families.is_empty() {
20337        println!();
20338        println!("Line families (near-duplicate folds):");
20339        for family in &report.line_families {
20340            println!(
20341                "  {}x ({} variants) {}",
20342                family.occurrences,
20343                family.variants,
20344                truncate_for_compact(&family.template, 180)
20345            );
20346            println!(
20347                "    first: {}",
20348                truncate_for_compact(&family.first_sample, 180)
20349            );
20350            println!(
20351                "    last:  {}",
20352                truncate_for_compact(&family.last_sample, 180)
20353            );
20354        }
20355    }
20356
20357    if !report.file_refs.is_empty() {
20358        println!();
20359        println!("Anchored files:");
20360        for file_ref in &report.file_refs {
20361            match (file_ref.line, file_ref.column) {
20362                (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
20363                (Some(line), None) => println!("{}:{}", file_ref.path, line),
20364                (None, _) => println!("{}", file_ref.path),
20365            }
20366            println!("  occurrences: {}", file_ref.occurrences);
20367            println!(
20368                "  cached summaries: {}",
20369                log_digest_summary_label(file_ref.summary_state)
20370            );
20371            for summary in &file_ref.current_summaries {
20372                println!(
20373                    "    - {}: {}",
20374                    summary.symbol,
20375                    truncate_for_compact(&summary.summary, 160)
20376                );
20377            }
20378        }
20379    }
20380
20381    if !report.symbol_refs.is_empty() {
20382        println!();
20383        println!("Symbol candidates:");
20384        for symbol in &report.symbol_refs {
20385            println!("{}", symbol.symbol);
20386            println!("  occurrences: {}", symbol.occurrences);
20387            println!(
20388                "  cached summaries: {}",
20389                log_digest_summary_label(symbol.summary_state)
20390            );
20391            for summary in &symbol.current_summaries {
20392                println!(
20393                    "    - {}: {}",
20394                    summary.symbol,
20395                    truncate_for_compact(&summary.summary, 160)
20396                );
20397            }
20398        }
20399    }
20400
20401    if !report.stack_traces.is_empty() {
20402        println!();
20403        println!("Stack groups:");
20404        for stack in &report.stack_traces {
20405            println!("  occurrences: {}", stack.occurrences);
20406            for frame in &stack.frames {
20407                println!("    - {}", frame);
20408            }
20409        }
20410    }
20411
20412    if let Some(artifact) = &report.raw_log_artifact {
20413        println!();
20414        println!("Raw log artifact:");
20415        println!("  handle: {}", artifact.handle);
20416        println!("  path:   {}", artifact.path);
20417        println!("  lines:  {}", artifact.lines);
20418        println!("  bytes:  {}", artifact.bytes);
20419        println!("  expand: {}", artifact.expand);
20420    }
20421
20422    for warning in &report.warnings {
20423        println!("warning: {warning}");
20424    }
20425    Ok(())
20426}
20427
20428pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
20429    match trend {
20430        metric_digest::MetricDigestTrend::Improved => "improved",
20431        metric_digest::MetricDigestTrend::Regressed => "regressed",
20432        metric_digest::MetricDigestTrend::Flat => "flat",
20433        metric_digest::MetricDigestTrend::Unknown => "changed",
20434    }
20435}
20436
20437pub(crate) fn metric_digest_gate_label(
20438    decision: metric_digest::CommunitySearchGateDecision,
20439) -> &'static str {
20440    match decision {
20441        metric_digest::CommunitySearchGateDecision::Pass => "pass",
20442        metric_digest::CommunitySearchGateDecision::Block => "block",
20443    }
20444}
20445
20446pub(crate) fn memgraphrag_metric_digest_gate_label(
20447    decision: metric_digest::MemGraphRagPerformanceGateDecision,
20448) -> &'static str {
20449    match decision {
20450        metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
20451        metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
20452    }
20453}
20454
20455fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
20456    let input = fs::read_to_string(fixture_path)
20457        .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
20458    let report = dci_benchmark::compute(&input)?;
20459
20460    if format.json_output {
20461        println!(
20462            "{}",
20463            to_json_schema(
20464                &report,
20465                format.pretty,
20466                format.terse,
20467                format.ultra_terse,
20468                format.schema
20469            )?
20470        );
20471        return Ok(());
20472    }
20473
20474    if format.compact {
20475        println!(
20476            "dci tasks:{} strategies:{} warnings:{}",
20477            report.tasks_loaded,
20478            report.strategies_compared,
20479            report.warnings.len()
20480        );
20481        for summary in &report.strategy_summaries {
20482            println!(
20483                "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
20484                summary.strategy,
20485                summary.rank,
20486                summary.localized,
20487                summary.task_runs,
20488                dci_benchmark::format_number(summary.localization_rate * 100.0),
20489                dci_benchmark::format_number(summary.avg_useful_hits),
20490                dci_benchmark::format_number(summary.zero_output_rate * 100.0),
20491                dci_benchmark::format_number(summary.avg_tool_calls),
20492                dci_benchmark::format_number(summary.avg_latency_ms),
20493                dci_benchmark::format_number(summary.avg_estimated_tokens),
20494                dci_benchmark::format_number(summary.avg_output_tokens)
20495            );
20496        }
20497        if let Some(gate) = &report.memory_retrieval_gate {
20498            println!(
20499                "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
20500                gate.decision,
20501                gate.baseline_strategy,
20502                dci_benchmark::format_number(gate.min_avg_useful_hits),
20503                gate.max_zero_output_failures,
20504                gate.diagnostics.len()
20505            );
20506        }
20507        for warning in &report.warnings {
20508            println!("warning: {warning}");
20509        }
20510        return Ok(());
20511    }
20512
20513    println!("DCI benchmark");
20514    if let Some(description) = &report.description {
20515        println!("  description: {}", description);
20516    }
20517    println!("  tasks loaded:        {}", report.tasks_loaded);
20518    println!("  strategies compared: {}", report.strategies_compared);
20519
20520    println!();
20521    println!("Strategy summary:");
20522    for summary in &report.strategy_summaries {
20523        println!(
20524            "  #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
20525            summary.rank,
20526            summary.strategy,
20527            summary.localized,
20528            summary.task_runs,
20529            summary.localization_rate * 100.0,
20530            dci_benchmark::format_number(summary.avg_useful_hits),
20531            summary.zero_output_rate * 100.0,
20532            dci_benchmark::format_number(summary.avg_tool_calls),
20533            dci_benchmark::format_number(summary.avg_latency_ms),
20534            dci_benchmark::format_number(summary.avg_estimated_tokens),
20535            dci_benchmark::format_number(summary.avg_output_tokens)
20536        );
20537    }
20538
20539    if let Some(gate) = &report.memory_retrieval_gate {
20540        println!();
20541        println!("Memory retrieval gate:");
20542        println!("  decision: {}", gate.decision);
20543        println!(
20544            "  baseline: {}, min avg useful hits {}, max zero-output failures {}",
20545            gate.baseline_strategy,
20546            dci_benchmark::format_number(gate.min_avg_useful_hits),
20547            gate.max_zero_output_failures
20548        );
20549        for row in &gate.rows {
20550            println!(
20551                "  {}: status {}, avg useful hits {}, zero-output failures {}",
20552                row.strategy,
20553                row.status,
20554                dci_benchmark::format_number(row.avg_useful_hits),
20555                row.zero_output_failures
20556            );
20557        }
20558        for diagnostic in &gate.diagnostics {
20559            println!("  diagnostic: {diagnostic}");
20560        }
20561    }
20562
20563    println!();
20564    println!("Task winners:");
20565    for row in &report.task_rows {
20566        let label = row
20567            .label
20568            .as_ref()
20569            .map(|value| format!(" ({value})"))
20570            .unwrap_or_default();
20571        println!("  {}{}", row.task_id, label);
20572        println!("    localized: {}", row.best_localization.join(", "));
20573        println!("    most useful hits: {}", row.most_useful_hits.join(", "));
20574        println!(
20575            "    lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
20576            row.lowest_tool_calls.as_deref().unwrap_or("-"),
20577            row.lowest_latency.as_deref().unwrap_or("-"),
20578            row.lowest_token_budget.as_deref().unwrap_or("-"),
20579            row.lowest_output_tokens.as_deref().unwrap_or("-")
20580        );
20581        if !row.zero_output_failures.is_empty() {
20582            println!("    zero output: {}", row.zero_output_failures.join(", "));
20583        }
20584    }
20585
20586    for warning in &report.warnings {
20587        println!("warning: {warning}");
20588    }
20589    Ok(())
20590}
20591
20592pub(crate) fn format_compact_count(value: u64) -> String {
20593    if value >= 1_000_000 {
20594        format!("{:.1}M", value as f64 / 1_000_000.0)
20595    } else if value >= 1_000 {
20596        format!("{:.1}K", value as f64 / 1_000.0)
20597    } else {
20598        value.to_string()
20599    }
20600}
20601
20602fn cmd_digest_runner(
20603    kind: &str,
20604    path: &Path,
20605    runner: Option<&str>,
20606    shell_command: &str,
20607    format: OutputFormat,
20608) -> Result<()> {
20609    let digest_kind = DigestRunnerKind::parse(kind)?;
20610    let root = transcript_artifact_root(path)?;
20611    let execution = run_digest_runner_command(shell_command)?;
20612    let output = &execution.output;
20613    let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20614    let exit_code = output.status.code().unwrap_or(-1);
20615    if format.json_output && format.envelope {
20616        let artifact_key = format!(
20617            "{}:{}:{}:{}",
20618            digest_kind.as_str(),
20619            shell_command,
20620            execution.executed_command,
20621            captured
20622        );
20623        let artifact = if captured.trim().is_empty() {
20624            None
20625        } else {
20626            let (suffix, expand) = match digest_kind {
20627                DigestRunnerKind::Test => (
20628                    "test.log",
20629                    format!(
20630                        "tsift test-digest --path {} --input {}{} --json",
20631                        shell_quote(root.to_string_lossy().as_ref()),
20632                        shell_quote(
20633                            root.join(".tsift/artifacts")
20634                                .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20635                                .to_string_lossy()
20636                                .as_ref()
20637                        ),
20638                        runner
20639                            .map(|value| format!(" --runner {}", shell_quote(value)))
20640                            .unwrap_or_default()
20641                    ),
20642                ),
20643                DigestRunnerKind::Log => (
20644                    "log",
20645                    format!(
20646                        "tsift log-digest --path {} --input {} --json",
20647                        shell_quote(root.to_string_lossy().as_ref()),
20648                        shell_quote(
20649                            root.join(".tsift/artifacts")
20650                                .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20651                                .to_string_lossy()
20652                                .as_ref()
20653                        )
20654                    ),
20655                ),
20656            };
20657            Some(persist_transcript_artifact(
20658                &root,
20659                "tart",
20660                suffix,
20661                &artifact_key,
20662                &captured,
20663                expand,
20664            )?)
20665        };
20666        let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20667
20668        match digest_kind {
20669            DigestRunnerKind::Test => {
20670                let digest_report = test_digest::compute(path, &captured, runner)?;
20671                let report = serde_json::json!({
20672                    "kind": digest_kind.as_str(),
20673                    "command": shell_command,
20674                    "executed_command": execution.executed_command,
20675                    "exit_code": exit_code,
20676                    "success": output.status.success(),
20677                    "filter": filter_report,
20678                    "artifact": artifact,
20679                    "digest": digest_report,
20680                });
20681                let mut follow_up = artifact
20682                    .as_ref()
20683                    .map(|entry| vec![entry.expand.clone()])
20684                    .unwrap_or_default();
20685                follow_up.push(format!(
20686                    "tsift rewrite --run {}",
20687                    shell_quote(shell_command)
20688                ));
20689                let summary_text = if output.status.success() && digest_report.failures == 0 {
20690                    format!("test run passed for {}", runner.unwrap_or("auto"))
20691                } else {
20692                    format!("test run captured {} failure(s)", digest_report.failures)
20693                };
20694                print_json_or_envelope(
20695                    &report,
20696                    &format,
20697                    "digest-runner",
20698                    "test-run",
20699                    ToolEnvelopeSummary {
20700                        text: summary_text,
20701                        metrics: vec![
20702                            envelope_metric("runner", &digest_report.runner),
20703                            envelope_metric("exit_code", exit_code),
20704                            envelope_metric("filter", execution.filter_label()),
20705                            envelope_metric("failures", digest_report.failures),
20706                            envelope_metric("groups", digest_report.grouped_failures),
20707                            envelope_metric(
20708                                "artifact",
20709                                artifact
20710                                    .as_ref()
20711                                    .map(|entry| entry.handle.as_str())
20712                                    .unwrap_or("-"),
20713                            ),
20714                        ],
20715                    },
20716                    false,
20717                    follow_up,
20718                )?;
20719            }
20720            DigestRunnerKind::Log => {
20721                let digest_report = log_digest::compute(path, &captured)?;
20722                let report = serde_json::json!({
20723                    "kind": digest_kind.as_str(),
20724                    "command": shell_command,
20725                    "executed_command": execution.executed_command,
20726                    "exit_code": exit_code,
20727                    "success": output.status.success(),
20728                    "filter": filter_report,
20729                    "artifact": artifact,
20730                    "digest": digest_report,
20731                });
20732                let mut follow_up = artifact
20733                    .as_ref()
20734                    .map(|entry| vec![entry.expand.clone()])
20735                    .unwrap_or_default();
20736                follow_up.push(format!(
20737                    "tsift rewrite --run {}",
20738                    shell_quote(shell_command)
20739                ));
20740                let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20741                    "command finished without log signals".to_string()
20742                } else {
20743                    format!(
20744                        "command emitted {} log signal group(s)",
20745                        digest_report.signal_groups
20746                    )
20747                };
20748                print_json_or_envelope(
20749                    &report,
20750                    &format,
20751                    "digest-runner",
20752                    "command-run",
20753                    ToolEnvelopeSummary {
20754                        text: summary_text,
20755                        metrics: vec![
20756                            envelope_metric("exit_code", exit_code),
20757                            envelope_metric("filter", execution.filter_label()),
20758                            envelope_metric("signals", digest_report.signal_groups),
20759                            envelope_metric("file_refs", digest_report.file_ref_groups),
20760                            envelope_metric(
20761                                "artifact",
20762                                artifact
20763                                    .as_ref()
20764                                    .map(|entry| entry.handle.as_str())
20765                                    .unwrap_or("-"),
20766                            ),
20767                        ],
20768                    },
20769                    false,
20770                    follow_up,
20771                )?;
20772            }
20773        }
20774
20775        if output.status.success() {
20776            return Ok(());
20777        }
20778        if let Some(code) = output.status.code() {
20779            std::process::exit(code);
20780        }
20781        bail!("digest-wrapped command terminated by signal: {shell_command}");
20782    }
20783
20784    if captured.trim().is_empty() {
20785        let label = match digest_kind {
20786            DigestRunnerKind::Test => "test",
20787            DigestRunnerKind::Log => "log",
20788        };
20789        println!("No {label} output captured.");
20790    } else {
20791        match digest_kind {
20792            DigestRunnerKind::Test => {
20793                render_test_digest_from_input(path, &captured, runner, format)?
20794            }
20795            DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20796        }
20797    }
20798
20799    if output.status.success() {
20800        return Ok(());
20801    }
20802    if let Some(code) = output.status.code() {
20803        std::process::exit(code);
20804    }
20805    bail!("digest-wrapped command terminated by signal: {shell_command}");
20806}
20807
20808struct DigestRunnerExecution {
20809    output: std::process::Output,
20810    executed_command: String,
20811    filter: Option<DigestRunnerFilter>,
20812}
20813
20814impl DigestRunnerExecution {
20815    fn filter_label(&self) -> &'static str {
20816        self.filter
20817            .as_ref()
20818            .map(|filter| filter.tool)
20819            .unwrap_or("none")
20820    }
20821}
20822
20823struct DigestRunnerFilter {
20824    tool: &'static str,
20825    command: String,
20826}
20827
20828impl DigestRunnerFilter {
20829    fn to_json(&self) -> serde_json::Value {
20830        serde_json::json!({
20831            "tool": self.tool,
20832            "command": self.command,
20833        })
20834    }
20835}
20836
20837fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20838    let filter = rtk_rewrite_for_digest_runner(shell_command);
20839    let executed_command = filter
20840        .as_ref()
20841        .map(|filter| filter.command.as_str())
20842        .unwrap_or(shell_command);
20843    let output = Command::new("sh")
20844        .arg("-lc")
20845        .arg(format!("({executed_command}) 2>&1"))
20846        .stdout(Stdio::piped())
20847        .output()
20848        .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20849
20850    Ok(DigestRunnerExecution {
20851        output,
20852        executed_command: executed_command.to_string(),
20853        filter,
20854    })
20855}
20856
20857fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20858    if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20859        return None;
20860    }
20861    let output = Command::new("rtk")
20862        .arg("rewrite")
20863        .arg(shell_command)
20864        .output()
20865        .ok()?;
20866    if !output.status.success() {
20867        return None;
20868    }
20869    let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20870    if rewritten.is_empty() || rewritten == shell_command {
20871        return None;
20872    }
20873    Some(DigestRunnerFilter {
20874        tool: "rtk",
20875        command: rewritten,
20876    })
20877}
20878
20879fn find_command_on_path(command: &str) -> Option<PathBuf> {
20880    let path_var = std::env::var_os("PATH")?;
20881    std::env::split_paths(&path_var)
20882        .map(|dir| dir.join(command))
20883        .find(|candidate| candidate.is_file())
20884}
20885
20886pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20887    if !db_path.exists() {
20888        bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20889    }
20890    summarize::SummaryDb::open_read_only_resilient(db_path)
20891}
20892
20893fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20894    !matches!(report.index, status::IndexStatus::Fresh { .. })
20895}
20896
20897fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20898    !matches!(report.instructions, init::InstructionStatus::Current { .. })
20899}
20900
20901pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20902    if status_instructions_need_fix(report) {
20903        eprintln!("status fix: refreshing tsift instructions");
20904        init::init(root, false, false)?;
20905    }
20906
20907    let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20908        root,
20909        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20910        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20911    );
20912    if eviction.evicted_entries > 0 {
20913        eprintln!(
20914            "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20915            eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
20916        );
20917    }
20918
20919    if !status_index_needs_fix(report) {
20920        return Ok(());
20921    }
20922
20923    let scopes = config::Config::submodule_dirs(root)?;
20924    if scopes.is_empty() {
20925        eprintln!("status fix: refreshing index");
20926        run_index_update(
20927            &root.join(".tsift/index.db"),
20928            root,
20929            "status --fix refreshing index".to_string(),
20930            root,
20931            None,
20932            false,
20933            false,
20934        )?;
20935        return Ok(());
20936    }
20937
20938    let cfg = config::Config::load(root)?;
20939    for scope in scopes {
20940        if !scope.source_root.exists() {
20941            eprintln!(
20942                "status fix: skipping missing submodule `{}` ({})",
20943                scope.id,
20944                scope.source_root.display()
20945            );
20946            continue;
20947        }
20948        eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20949        run_index_update(
20950            &cfg.db_path_for(root, &scope.id),
20951            &scope.source_root,
20952            format!("status --fix refreshing submodule `{}` index", scope.id),
20953            root,
20954            Some(scope.id.as_str()),
20955            false,
20956            false,
20957        )?;
20958    }
20959
20960    Ok(())
20961}
20962
20963pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20964    match &report.index {
20965        status::IndexStatus::Fresh { missing_scopes, .. }
20966        | status::IndexStatus::Stale { missing_scopes, .. }
20967        | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20968    }
20969}
20970
20971pub(crate) fn autoindex_missing_workspace_scopes(
20972    root: &Path,
20973    report: &status::StatusReport,
20974) -> Result<()> {
20975    let missing_scopes = match &report.index {
20976        status::IndexStatus::Fresh { missing_scopes, .. }
20977        | status::IndexStatus::Stale { missing_scopes, .. }
20978        | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20979    };
20980    if missing_scopes.is_empty() {
20981        return Ok(());
20982    }
20983
20984    let missing_scope_ids = missing_scopes
20985        .iter()
20986        .map(|scope| scope.scope.as_str())
20987        .collect::<std::collections::HashSet<_>>();
20988    let cfg = config::Config::load(root)?;
20989    for scope in config::Config::submodule_dirs(root)? {
20990        if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20991            continue;
20992        }
20993        let db_path = cfg.db_path_for(root, &scope.id);
20994        run_index_update(
20995            &db_path,
20996            &scope.source_root,
20997            format!(
20998                "autoindexing missing submodule `{}` during status",
20999                scope.id
21000            ),
21001            root,
21002            Some(scope.id.as_str()),
21003            false,
21004            false,
21005        )?;
21006    }
21007    Ok(())
21008}
21009
21010pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
21011    for warning in &stats.warnings {
21012        let rel_path = relativize_pathbuf(&warning.path, root);
21013        eprintln!(
21014            "warning: summarize stats {}: {}",
21015            rel_path.display(),
21016            warning.message
21017        );
21018    }
21019}
21020
21021fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
21022    Result::<(), anyhow::Error>::Err(err)
21023        .context(context)
21024        .unwrap_err()
21025}
21026
21027fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
21028    let message = err.to_string();
21029    message.contains("another tsift index writer is already active")
21030        || substrate::error_mentions_locked_db(err)
21031}
21032
21033fn add_write_lock_context(
21034    err: anyhow::Error,
21035    action: String,
21036    root: &std::path::Path,
21037    scope: Option<&str>,
21038) -> anyhow::Error {
21039    if !should_attach_lock_diagnostics(&err) {
21040        return contextualize_error(err, action);
21041    }
21042
21043    let Ok(report) = status::check_locks(root, None, scope) else {
21044        return contextualize_error(err, action);
21045    };
21046
21047    contextualize_error(
21048        err,
21049        format!(
21050            "{}\n\nlock diagnostics:\n{}",
21051            action,
21052            status::format_locks_human(&report, false).trim_end()
21053        ),
21054    )
21055}
21056
21057pub(crate) fn run_index_update(
21058    db_path: &std::path::Path,
21059    source_root: &std::path::Path,
21060    action: String,
21061    root: &std::path::Path,
21062    scope: Option<&str>,
21063    rebuild: bool,
21064    prune: bool,
21065) -> Result<index::IndexSummary> {
21066    let result = (|| {
21067        let db = index::IndexDb::open(db_path)?;
21068        if rebuild {
21069            db.rebuild(source_root)
21070        } else if prune {
21071            db.apply_changes_pruned(source_root)
21072        } else {
21073            db.apply_changes(source_root)
21074        }
21075    })();
21076
21077    let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
21078    emit_index_warnings(&summary, source_root, scope);
21079    Ok(summary)
21080}
21081
21082pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
21083    for change in &mut summary.changes {
21084        change.path = relativize_pathbuf(&change.path, root);
21085    }
21086    for warning in &mut summary.warnings {
21087        warning.path = relativize_pathbuf(&warning.path, root);
21088    }
21089}
21090
21091fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
21092    for warning in &summary.warnings {
21093        let rel_path = relativize_pathbuf(&warning.path, root);
21094        let stage = match warning.stage {
21095            index::IndexWarningStage::ReadSource => "read failed",
21096            index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
21097            index::IndexWarningStage::ExtractCallSites => "call extraction failed",
21098            index::IndexWarningStage::ExtractRoutes => "route extraction failed",
21099        };
21100        let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
21101        let lang_suffix = warning
21102            .language
21103            .as_deref()
21104            .map(|lang| format!(" [{}]", lang))
21105            .unwrap_or_default();
21106        eprintln!(
21107            "warning: {}{}{}: {}: {}",
21108            scope_prefix,
21109            rel_path.display(),
21110            lang_suffix,
21111            stage,
21112            warning.message
21113        );
21114    }
21115}
21116
21117pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
21118    let config_path = root.join(".tsift/config.toml");
21119    if !config_path.exists() {
21120        return summarize::SummarizeConfig::default();
21121    }
21122    #[derive(serde::Deserialize, Default)]
21123    struct RawConfig {
21124        #[serde(default)]
21125        summarize: Option<RawSummarize>,
21126    }
21127    #[derive(serde::Deserialize)]
21128    struct RawSummarize {
21129        model: Option<String>,
21130        max_file_tokens: Option<usize>,
21131        api_key_env: Option<String>,
21132    }
21133    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
21134    let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
21135    let defaults = summarize::SummarizeConfig::default();
21136    match raw.summarize {
21137        Some(s) => summarize::SummarizeConfig {
21138            model: s.model.unwrap_or(defaults.model),
21139            max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
21140            api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
21141        },
21142        None => defaults,
21143    }
21144}
21145
21146#[derive(Debug, Clone, PartialEq, Eq)]
21147struct ExtractSymbolContext {
21148    db_path: PathBuf,
21149    source_root: PathBuf,
21150}
21151
21152pub(crate) fn find_symbols_db_for_file(
21153    root: &Path,
21154    file_path: &Path,
21155) -> Result<Option<ExtractSymbolContext>> {
21156    let cfg = config::Config::load(root)?;
21157    let mut submodules = config::Config::submodule_dirs(root)?;
21158    submodules.sort_by(|left, right| {
21159        right
21160            .source_root
21161            .components()
21162            .count()
21163            .cmp(&left.source_root.components().count())
21164    });
21165
21166    for scope in submodules {
21167        if !file_path.starts_with(&scope.source_root) {
21168            continue;
21169        }
21170        let db_path = cfg.db_path_for(root, &scope.id);
21171        if db_path.exists() {
21172            return Ok(Some(ExtractSymbolContext {
21173                db_path,
21174                source_root: scope.source_root,
21175            }));
21176        }
21177    }
21178
21179    let single = root.join(".tsift/index.db");
21180    if single.exists() && file_path.starts_with(root) {
21181        return Ok(Some(ExtractSymbolContext {
21182            db_path: single,
21183            source_root: root.to_path_buf(),
21184        }));
21185    }
21186
21187    Ok(None)
21188}
21189
21190pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
21191    let canonical = path
21192        .canonicalize()
21193        .with_context(|| format!("canonicalizing {}", path.display()))?;
21194
21195    Ok(if canonical.is_dir() {
21196        canonical
21197    } else {
21198        canonical
21199            .parent()
21200            .map(Path::to_path_buf)
21201            .unwrap_or(canonical)
21202    })
21203}
21204
21205fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
21206    if path.exists() {
21207        return path
21208            .canonicalize()
21209            .with_context(|| format!("canonicalizing extract scope {}", path.display()));
21210    }
21211
21212    Ok(summarize::normalize_lexical_path(path))
21213}
21214
21215pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
21216    let scope = if extract_path.is_absolute() {
21217        extract_path.to_path_buf()
21218    } else {
21219        root.join(extract_path)
21220    };
21221    normalize_extract_scope_path(&scope)
21222}
21223
21224pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
21225    normalize_extract_scope_path(changed_path)
21226        .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
21227        .starts_with(extract_scope)
21228}
21229
21230pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
21231    summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
21232}
21233
21234pub(crate) fn summarize_full_extract_deleted_summary_paths(
21235    summary_db: &summarize::SummaryDb,
21236    root: &Path,
21237    extract_scope: &Path,
21238    files_to_extract: &[PathBuf],
21239) -> Result<BTreeSet<String>> {
21240    let live_paths = files_to_extract
21241        .iter()
21242        .map(|file_path| summarize_relative_file_path(root, file_path))
21243        .collect::<BTreeSet<_>>();
21244    let mut deleted = BTreeSet::new();
21245
21246    for cached_path in summary_db.cached_file_paths()? {
21247        if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
21248            continue;
21249        }
21250        if !live_paths.contains(&cached_path) {
21251            deleted.insert(cached_path);
21252        }
21253    }
21254
21255    Ok(deleted)
21256}
21257
21258#[derive(Debug, Clone)]
21259struct SearchIndexTarget {
21260    label: String,
21261    db_path: PathBuf,
21262    source_root: PathBuf,
21263    scope_name: Option<String>,
21264    reindex_cmd: String,
21265}
21266
21267fn cargo_package_index_target(
21268    root: &Path,
21269    package: multiplicity::CargoPackageInfo,
21270) -> SearchIndexTarget {
21271    SearchIndexTarget {
21272        label: format!("cargo package `{}` index", package.scope_id),
21273        db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
21274        source_root: package.package_root.clone(),
21275        scope_name: Some(package.scope_id.clone()),
21276        reindex_cmd: format!(
21277            "tsift index --submodule {} {}",
21278            package.scope_id,
21279            root.display()
21280        ),
21281    }
21282}
21283
21284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21285enum SearchIndexState {
21286    Missing,
21287    Fresh,
21288    Stale { stale_files: usize },
21289}
21290
21291fn resolve_search_index_targets(
21292    root: &Path,
21293    path_hint: &Path,
21294    scope: Option<&str>,
21295    federated: bool,
21296) -> Result<Vec<SearchIndexTarget>> {
21297    if let Some(scope_name) = scope {
21298        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
21299            let cfg = config::Config::load(root)?;
21300            return Ok(vec![SearchIndexTarget {
21301                label: format!("submodule `{}` index", scope.id),
21302                db_path: cfg.db_path_for(root, &scope.id),
21303                source_root: scope.source_root.clone(),
21304                scope_name: Some(scope.id.clone()),
21305                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21306            }]);
21307        }
21308        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
21309            return Ok(vec![cargo_package_index_target(root, package)]);
21310        }
21311        config::Config::resolve_submodule(root, scope_name)?;
21312    }
21313
21314    if federated {
21315        let cfg = config::Config::load(root)?;
21316        let mut targets = Vec::new();
21317        for scope in config::Config::submodule_dirs(root)? {
21318            if !cfg.federation_for_scope(&scope) {
21319                continue;
21320            }
21321            targets.push(SearchIndexTarget {
21322                label: format!("submodule `{}` index", scope.id),
21323                db_path: cfg.db_path_for(root, &scope.id),
21324                source_root: scope.source_root.clone(),
21325                scope_name: Some(scope.id.clone()),
21326                reindex_cmd: format!("tsift index --workspace {}", root.display()),
21327            });
21328        }
21329        return Ok(targets);
21330    }
21331
21332    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
21333        let cfg = config::Config::load(root)?;
21334        return Ok(vec![SearchIndexTarget {
21335            label: format!("submodule `{}` index", scope.id),
21336            db_path: cfg.db_path_for(root, &scope.id),
21337            source_root: scope.source_root.clone(),
21338            scope_name: Some(scope.id.clone()),
21339            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21340        }]);
21341    }
21342
21343    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
21344        return Ok(vec![cargo_package_index_target(root, package)]);
21345    }
21346
21347    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
21348        let cfg = config::Config::load(root)?;
21349        return Ok(vec![SearchIndexTarget {
21350            label: format!("submodule `{}` index", scope.id),
21351            db_path: cfg.db_path_for(root, &scope.id),
21352            source_root: scope.source_root.clone(),
21353            scope_name: Some(scope.id.clone()),
21354            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21355        }]);
21356    }
21357
21358    let scopes = config::Config::submodule_dirs(root)?;
21359    if !scopes.is_empty() {
21360        let root_db = root.join(".tsift/index.db");
21361        if !root_db.exists() {
21362            let available_scopes = scopes
21363                .iter()
21364                .map(|scope| scope.id.as_str())
21365                .collect::<Vec<_>>()
21366                .join(", ");
21367            let cfg = config::Config::load(root)?;
21368            let indexed_scopes = scopes
21369                .iter()
21370                .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
21371                .map(|scope| scope.id.as_str())
21372                .collect::<Vec<_>>();
21373            let indexed_label = if indexed_scopes.is_empty() {
21374                "none".to_string()
21375            } else {
21376                indexed_scopes.join(", ")
21377            };
21378            bail!(
21379                "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: {}.",
21380                root.display(),
21381                root_db.display(),
21382                available_scopes,
21383                indexed_label,
21384            );
21385        }
21386    }
21387
21388    Ok(vec![SearchIndexTarget {
21389        label: "index".to_string(),
21390        db_path: root.join(".tsift/index.db"),
21391        source_root: root.to_path_buf(),
21392        scope_name: None,
21393        reindex_cmd: format!("tsift index {}", root.display()),
21394    }])
21395}
21396
21397fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
21398    if !target.source_root.exists() || !target.db_path.exists() {
21399        return Ok(SearchIndexState::Missing);
21400    }
21401
21402    let inspection =
21403        index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
21404    let stale_files =
21405        inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
21406    if stale_files == 0 {
21407        Ok(SearchIndexState::Fresh)
21408    } else {
21409        Ok(SearchIndexState::Stale { stale_files })
21410    }
21411}
21412
21413#[derive(Debug, Clone, PartialEq, Eq)]
21414struct RebuildSearchTarget {
21415    label: String,
21416    reason: RebuildSearchReason,
21417    reindex_cmd: String,
21418}
21419
21420#[derive(Debug, Clone, PartialEq, Eq)]
21421enum RebuildSearchReason {
21422    Missing,
21423    Stale { stale_files: usize },
21424}
21425
21426#[derive(Debug, Clone, PartialEq, Eq)]
21427struct DegradedSearchTarget {
21428    label: String,
21429    reason: RebuildSearchReason,
21430    reindex_cmd: String,
21431}
21432
21433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21434pub(crate) enum DegradedSearchMode {
21435    ReadOnly,
21436    Exact,
21437}
21438
21439#[derive(Debug)]
21440struct SearchPrecheck {
21441    targets: Vec<SearchIndexTarget>,
21442    degraded_targets: Vec<DegradedSearchTarget>,
21443}
21444
21445fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
21446    err.chain().any(|cause| {
21447        cause
21448            .to_string()
21449            .contains("another tsift index writer is already active")
21450    })
21451}
21452
21453fn infer_agent_doc_task_submodule(
21454    root: &Path,
21455    path_hint: &Path,
21456) -> Result<Option<config::WorkspaceScope>> {
21457    let hinted_path = if path_hint.is_absolute() {
21458        path_hint.to_path_buf()
21459    } else {
21460        root.join(path_hint)
21461    };
21462    let Ok(relative) = hinted_path.strip_prefix(root) else {
21463        return Ok(None);
21464    };
21465    let mut components = relative.components();
21466    let Some(std::path::Component::Normal(first)) = components.next() else {
21467        return Ok(None);
21468    };
21469    if first != "tasks" {
21470        return Ok(None);
21471    }
21472    let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
21473        return Ok(None);
21474    };
21475    config::Config::find_submodule(root, file_stem)
21476}
21477
21478fn degraded_search_target(
21479    target: &SearchIndexTarget,
21480    reason: RebuildSearchReason,
21481) -> DegradedSearchTarget {
21482    DegradedSearchTarget {
21483        label: target.label.clone(),
21484        reason,
21485        reindex_cmd: target.reindex_cmd.clone(),
21486    }
21487}
21488
21489fn apply_search_index_update(
21490    root: &Path,
21491    target: &SearchIndexTarget,
21492) -> Result<index::IndexSummary> {
21493    run_index_update(
21494        &target.db_path,
21495        &target.source_root,
21496        format!("autoindexing {}", target.label),
21497        root,
21498        target.scope_name.as_deref(),
21499        false,
21500        false,
21501    )
21502}
21503
21504fn collect_rebuild_search_targets(
21505    targets: &[SearchIndexTarget],
21506) -> Result<Vec<RebuildSearchTarget>> {
21507    let mut rebuild_targets = Vec::new();
21508    for target in targets {
21509        let reason = match inspect_search_index(target)? {
21510            SearchIndexState::Missing => RebuildSearchReason::Missing,
21511            SearchIndexState::Fresh => continue,
21512            SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
21513        };
21514        rebuild_targets.push(RebuildSearchTarget {
21515            label: target.label.clone(),
21516            reason,
21517            reindex_cmd: target.reindex_cmd.clone(),
21518        });
21519    }
21520    Ok(rebuild_targets)
21521}
21522
21523fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
21524    match target.reason {
21525        RebuildSearchReason::Missing => format!("{} is missing", target.label),
21526        RebuildSearchReason::Stale { stale_files } => {
21527            let file_suffix = if stale_files == 1 { "" } else { "s" };
21528            format!(
21529                "{} is stale ({} file{})",
21530                target.label, stale_files, file_suffix
21531            )
21532        }
21533    }
21534}
21535
21536fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
21537    if rebuild_targets.len() == 1 {
21538        let target = &rebuild_targets[0];
21539        return format!(
21540            "{}. Run `{}` to rebuild before retrying.",
21541            rebuild_search_target_detail(target),
21542            target.reindex_cmd
21543        );
21544    }
21545
21546    let summary: Vec<String> = rebuild_targets
21547        .iter()
21548        .take(3)
21549        .map(rebuild_search_target_detail)
21550        .collect();
21551    let overflow = rebuild_targets.len().saturating_sub(summary.len());
21552    let mut details = summary.join(", ");
21553    if overflow > 0 {
21554        details.push_str(&format!(", +{} more", overflow));
21555    }
21556    let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
21557    format!(
21558        "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
21559        rebuild_targets.len(),
21560        details,
21561        reindex_cmd
21562    )
21563}
21564
21565pub(crate) fn precheck_search_indexes(
21566    root: &Path,
21567    path_hint: &Path,
21568    scope: Option<&str>,
21569    federated: bool,
21570    autoindex: bool,
21571) -> Result<SearchPrecheck> {
21572    let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
21573    let mut stale_targets = Vec::new();
21574    let mut degraded_targets = Vec::new();
21575
21576    for target in &targets {
21577        match inspect_search_index(target)? {
21578            SearchIndexState::Missing => {
21579                if autoindex && let Err(err) = apply_search_index_update(root, target) {
21580                    if is_active_writer_lock_error(&err) {
21581                        degraded_targets
21582                            .push(degraded_search_target(target, RebuildSearchReason::Missing));
21583                    } else {
21584                        return Err(err);
21585                    }
21586                }
21587            }
21588            SearchIndexState::Fresh => {}
21589            SearchIndexState::Stale { stale_files } => {
21590                if autoindex {
21591                    if let Err(err) = apply_search_index_update(root, target) {
21592                        if is_active_writer_lock_error(&err) {
21593                            degraded_targets.push(degraded_search_target(
21594                                target,
21595                                RebuildSearchReason::Stale { stale_files },
21596                            ));
21597                        } else {
21598                            return Err(err);
21599                        }
21600                    }
21601                } else {
21602                    stale_targets.push(RebuildSearchTarget {
21603                        label: target.label.clone(),
21604                        reason: RebuildSearchReason::Stale { stale_files },
21605                        reindex_cmd: target.reindex_cmd.clone(),
21606                    });
21607                }
21608            }
21609        }
21610    }
21611
21612    if stale_targets.is_empty() {
21613        return Ok(SearchPrecheck {
21614            targets,
21615            degraded_targets,
21616        });
21617    }
21618
21619    bail!(
21620        "tsift search aborted: {} \
21621         or re-run without `--no-autoindex`.",
21622        rebuild_search_targets_message(&stale_targets),
21623    );
21624}
21625
21626pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21627    if targets.is_empty() {
21628        return None;
21629    }
21630
21631    if targets
21632        .iter()
21633        .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21634    {
21635        Some(DegradedSearchMode::Exact)
21636    } else {
21637        Some(DegradedSearchMode::ReadOnly)
21638    }
21639}
21640
21641fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21642    if targets.len() == 1 {
21643        let target = &targets[0];
21644        return match target.reason {
21645            RebuildSearchReason::Missing => format!("{} is missing", target.label),
21646            RebuildSearchReason::Stale { stale_files } => {
21647                let file_suffix = if stale_files == 1 { "" } else { "s" };
21648                format!(
21649                    "{} is stale ({} file{})",
21650                    target.label, stale_files, file_suffix
21651                )
21652            }
21653        };
21654    }
21655
21656    let missing = targets
21657        .iter()
21658        .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21659        .count();
21660    let stale = targets.len().saturating_sub(missing);
21661    let mut parts = Vec::new();
21662    if stale > 0 {
21663        let suffix = if stale == 1 { "" } else { "es" };
21664        parts.push(format!("{stale} stale index{suffix}"));
21665    }
21666    if missing > 0 {
21667        let suffix = if missing == 1 { "" } else { "es" };
21668        parts.push(format!("{missing} missing index{suffix}"));
21669    }
21670    parts.join(", ")
21671}
21672
21673pub(crate) fn emit_degraded_search_note(
21674    targets: &[DegradedSearchTarget],
21675    mode: DegradedSearchMode,
21676) {
21677    let summary = degraded_search_targets_summary(targets);
21678    let reindex_cmd = &targets[0].reindex_cmd;
21679    match mode {
21680        DegradedSearchMode::ReadOnly => eprintln!(
21681            "note: active tsift writer detected; skipping autoindex because {}. \
21682             Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21683             Retry `{}` after the active writer finishes for fresh index results.",
21684            summary, reindex_cmd
21685        ),
21686        DegradedSearchMode::Exact => eprintln!(
21687            "note: active tsift writer detected; skipping autoindex because {}. \
21688             Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21689             for indexed symbol hits.",
21690            summary, reindex_cmd
21691        ),
21692    }
21693}
21694
21695fn search_timeout_message(
21696    timeout_secs: u64,
21697    strategy: &str,
21698    targets: &[SearchIndexTarget],
21699) -> Result<String> {
21700    let rebuild_targets = collect_rebuild_search_targets(targets)?;
21701    if rebuild_targets.is_empty() {
21702        return Ok(format!(
21703            "tsift search timed out after {}s (strategy: {}). \
21704             The search root looks fresh, so reindexing is unlikely to help. \
21705             Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21706             or try a different strategy.",
21707            timeout_secs, strategy,
21708        ));
21709    }
21710
21711    Ok(format!(
21712        "tsift search timed out after {}s (strategy: {}). {}",
21713        timeout_secs,
21714        strategy,
21715        rebuild_search_targets_message(&rebuild_targets),
21716    ))
21717}
21718
21719fn is_exact_preferring_query_char(ch: char) -> bool {
21720    matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21721}
21722
21723fn query_prefers_exact_search(query: &str) -> bool {
21724    let trimmed = query.trim();
21725    !trimmed.is_empty()
21726        && !trimmed.chars().any(char::is_whitespace)
21727        && trimmed.chars().any(|ch| ch.is_alphanumeric())
21728        && trimmed.chars().any(is_exact_preferring_query_char)
21729        && trimmed
21730            .chars()
21731            .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21732}
21733
21734pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21735    strategy.unwrap_or_else(|| {
21736        if query_prefers_exact_search(query) {
21737            "exact".to_string()
21738        } else {
21739            "lexical".to_string()
21740        }
21741    })
21742}
21743
21744pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21745    let mut files = Vec::new();
21746    if path.is_file() {
21747        files.push(path.to_path_buf());
21748        return Ok(files);
21749    }
21750    let walker = ignore::WalkBuilder::new(path)
21751        .hidden(true)
21752        .git_ignore(true)
21753        .build();
21754    for entry in walker {
21755        let entry = entry?;
21756        if entry.file_type().is_some_and(|ft| ft.is_file()) {
21757            let p = entry.path();
21758            if let Some(ext) = p.extension() {
21759                let ext = ext.to_string_lossy();
21760                if matches!(
21761                    ext.as_ref(),
21762                    "rs" | "py"
21763                        | "ts"
21764                        | "tsx"
21765                        | "js"
21766                        | "jsx"
21767                        | "kt"
21768                        | "kts"
21769                        | "zig"
21770                        | "sh"
21771                        | "bash"
21772                        | "zsh"
21773                ) {
21774                    files.push(p.to_path_buf());
21775                }
21776            }
21777        }
21778    }
21779    Ok(files)
21780}
21781
21782#[cfg(test)]
21783mod tests {
21784    use super::semantic_edit::{
21785        EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21786        markdown_section_spans,
21787    };
21788    use super::*;
21789    use tsift_memory::{MemoryEventKind, MemoryStore};
21790
21791    use std::cell::RefCell;
21792    use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21793
21794    #[test]
21795    fn graph_db_write_lock_serializes_concurrent_writers() {
21796        let dir = tempfile::tempdir().unwrap();
21797        let graph_db = dir.path().join(".tsift/graph.db");
21798        let short = Duration::from_millis(150);
21799
21800        let first = acquire_graph_db_write_lock_with_timeout(&graph_db, short)
21801            .expect("first writer acquires the lock");
21802        // A second acquire fails (bounded) while the first guard is held — this is
21803        // the cross-process mutual exclusion that protects refresh/snapshot-import.
21804        let second = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21805        assert!(
21806            second.is_err(),
21807            "a second writer must not acquire the graph-db write lock while it is held"
21808        );
21809        drop(first);
21810        // After release the lock is re-acquirable.
21811        let third = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21812        assert!(
21813            third.is_ok(),
21814            "graph-db write lock must be re-acquirable after release"
21815        );
21816    }
21817
21818    // #gdblockcover: `graph-db compact --apply` (DELETE + wal_checkpoint(TRUNCATE)
21819    // + VACUUM) must take the same advisory write lock as refresh/snapshot-import,
21820    // or its VACUUM races a concurrent refresh's WAL transaction. Hold the lock
21821    // externally and prove the compact blocks on it (rather than running unguarded
21822    // as it did before the fix), then completes once the lock is released.
21823    #[test]
21824    fn graph_db_compact_apply_blocks_on_held_write_lock() {
21825        let dir = setup_traversal_project();
21826        let session = dir.path().join("tasks/software/tsift.md");
21827        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
21828        let graph_db = graph_substrate_db_path(dir.path(), None);
21829
21830        let held = acquire_graph_db_write_lock(&graph_db).expect("hold writer lock");
21831
21832        let root = dir.path().to_path_buf();
21833        let handle = std::thread::Builder::new()
21834            .name("compact-apply".to_string())
21835            .stack_size(16 * 1024 * 1024)
21836            .spawn(move || {
21837                crate::commands::infra::cmd_graph_db_compact(
21838                    &root,
21839                    None,
21840                    true,
21841                    false,
21842                    false,
21843                    OutputFormat {
21844                        json_output: true,
21845                        compact: true,
21846                        pretty: false,
21847                        terse: false,
21848                        ultra_terse: false,
21849                        schema: false,
21850                        envelope: false,
21851                    },
21852                )
21853            })
21854            .unwrap();
21855
21856        // While the lock is held the compact cannot have finished — it must be
21857        // parked in the acquire loop. Before the fix it ran VACUUM unguarded and
21858        // would already be done here.
21859        std::thread::sleep(Duration::from_millis(300));
21860        assert!(
21861            !handle.is_finished(),
21862            "compact --apply must block on the held graph-db write lock, not run unguarded"
21863        );
21864
21865        drop(held);
21866        let result = handle.join().expect("compact thread joins");
21867        assert!(
21868            result.is_ok(),
21869            "compact --apply must succeed after the lock is released: {result:?}"
21870        );
21871    }
21872
21873    fn parse_cli<I, T>(itr: I) -> Cli
21874    where
21875        I: IntoIterator<Item = T> + Send + 'static,
21876        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21877    {
21878        std::thread::Builder::new()
21879            .name("cli-parse".to_string())
21880            .stack_size(16 * 1024 * 1024)
21881            .spawn(move || Cli::parse_from(itr))
21882            .unwrap()
21883            .join()
21884            .unwrap()
21885    }
21886
21887    fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21888    where
21889        I: IntoIterator<Item = T> + Send + 'static,
21890        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21891    {
21892        std::thread::Builder::new()
21893            .name("cli-try-parse".to_string())
21894            .stack_size(16 * 1024 * 1024)
21895            .spawn(move || Cli::try_parse_from(itr))
21896            .unwrap()
21897            .join()
21898            .unwrap()
21899    }
21900
21901    fn build_relative_search_budget_report(
21902        query: &str,
21903        strategy: &str,
21904        root: &Path,
21905        response: &sift::SearchResponse,
21906        symbol_hits: &[index::SymbolHit],
21907        budget: ResponseBudget,
21908        filters: &SearchFacetFilters,
21909    ) -> SearchBudgetReport {
21910        build_search_budget_report(SearchBudgetReportInput {
21911            query,
21912            strategy,
21913            root,
21914            response,
21915            symbol_hits,
21916            absolute: false,
21917            budget,
21918            filters,
21919        })
21920    }
21921
21922    #[derive(Default)]
21923    struct MemoryConvexGraphClient {
21924        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21925        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21926    }
21927
21928    impl ConvexGraphClient for MemoryConvexGraphClient {
21929        fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21930            self.nodes
21931                .borrow_mut()
21932                .insert(row.external_id.clone(), row.clone());
21933            Ok(())
21934        }
21935
21936        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21937            self.edges
21938                .borrow_mut()
21939                .insert(row.edge_key.clone(), row.clone());
21940            Ok(())
21941        }
21942
21943        fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21944            Ok(usize::from(
21945                self.nodes.borrow_mut().remove(external_id).is_some(),
21946            ))
21947        }
21948
21949        fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21950            Ok(usize::from(
21951                self.edges.borrow_mut().remove(edge_key).is_some(),
21952            ))
21953        }
21954
21955        fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21956            Ok(self.nodes.borrow().get(external_id).cloned())
21957        }
21958
21959        fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21960            Ok(self.nodes.borrow().values().cloned().collect())
21961        }
21962
21963        fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21964            Ok(self.edges.borrow().values().cloned().collect())
21965        }
21966
21967        fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21968            Ok(self
21969                .nodes
21970                .borrow()
21971                .values()
21972                .filter(|row| row.kind == kind)
21973                .cloned()
21974                .collect())
21975        }
21976
21977        fn outgoing_edge_rows(
21978            &self,
21979            from_external_id: &str,
21980            kind: Option<&str>,
21981        ) -> Result<Vec<ConvexEdgeRow>> {
21982            Ok(self
21983                .edges
21984                .borrow()
21985                .values()
21986                .filter(|row| row.from_external_id == from_external_id)
21987                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21988                .cloned()
21989                .collect())
21990        }
21991    }
21992
21993    fn init_git_repo(path: &Path) {
21994        let status = std::process::Command::new("git")
21995            .args(["init"])
21996            .current_dir(path)
21997            .status()
21998            .unwrap();
21999        assert!(status.success(), "git init failed");
22000
22001        let status = std::process::Command::new("git")
22002            .args(["add", "."])
22003            .current_dir(path)
22004            .status()
22005            .unwrap();
22006        assert!(status.success(), "git add failed");
22007
22008        let status = std::process::Command::new("git")
22009            .args([
22010                "-c",
22011                "user.name=tsift-tests",
22012                "-c",
22013                "user.email=tsift-tests@example.com",
22014                "commit",
22015                "--quiet",
22016                "-m",
22017                "init",
22018            ])
22019            .current_dir(path)
22020            .status()
22021            .unwrap();
22022        assert!(status.success(), "git commit failed");
22023    }
22024
22025    fn write_empty_root_index(root: &Path) {
22026        let index_dir = root.join(".tsift");
22027        fs::create_dir_all(&index_dir).unwrap();
22028        fs::write(index_dir.join("index.db"), "").unwrap();
22029    }
22030
22031    fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
22032        if let Some(parent) = path.parent() {
22033            fs::create_dir_all(parent).unwrap();
22034        }
22035        let body = std::iter::repeat_n(line, lines)
22036            .collect::<Vec<_>>()
22037            .join("\n");
22038        fs::write(path, format!("{body}\n")).unwrap();
22039        path.to_path_buf()
22040    }
22041
22042    // --- build_token_capped_preview ---
22043
22044    #[test]
22045    fn token_capped_preview_returns_all_lines_when_under_cap() {
22046        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 1", "}"];
22047        let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
22048        assert!(!result.was_capped);
22049        assert_eq!(result.preview.len(), 3);
22050        assert_eq!(result.capped_end, 3);
22051    }
22052
22053    #[test]
22054    fn token_capped_preview_truncates_when_over_cap() {
22055        let lines: Vec<&str> = (0..200)
22056            .map(|_| "    let x = some_very_long_expression_here();")
22057            .collect();
22058        let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
22059        assert!(result.was_capped);
22060        assert!(result.preview.len() < 200);
22061        assert!(result.capped_end < 200);
22062    }
22063
22064    #[test]
22065    fn token_capped_preview_keeps_at_least_one_line() {
22066        let long_line: String = "x".repeat(8000);
22067        let lines: Vec<&str> = vec![&long_line];
22068        let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
22069        assert!(!result.was_capped);
22070        assert_eq!(result.preview.len(), 1);
22071    }
22072
22073    #[test]
22074    fn token_capped_preview_cap_at_boundary() {
22075        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22076        let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
22077        assert!(!result.was_capped);
22078        assert_eq!(result.preview.len(), 4);
22079    }
22080
22081    #[test]
22082    fn token_capped_preview_cap_just_over_boundary() {
22083        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22084        let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
22085        assert!(result.was_capped);
22086        assert_eq!(result.preview.len(), 3);
22087        assert_eq!(result.capped_end, 3);
22088    }
22089
22090    #[test]
22091    fn token_capped_preview_empty_lines() {
22092        let lines: Vec<&str> = vec![];
22093        let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
22094        assert!(!result.was_capped);
22095        assert!(result.preview.is_empty());
22096    }
22097
22098    #[test]
22099    fn token_capped_preview_per_line_truncation_applied() {
22100        let long_line = "x".repeat(500);
22101        let lines: Vec<&str> = vec![&long_line, "short"];
22102        let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
22103        assert!(!result.was_capped);
22104        assert_eq!(result.preview.len(), 2);
22105        assert!(result.preview[0].text.len() <= 23);
22106        assert!(result.preview[0].text.ends_with("..."));
22107    }
22108
22109    // --- classify_task ---
22110
22111    #[test]
22112    fn route_search_defaults_to_haiku() {
22113        let (tier, model) = classify_task("find all uses of authenticate");
22114        assert_eq!(tier, "haiku");
22115        assert!(
22116            model.contains("haiku"),
22117            "expected haiku model, got {}",
22118            model
22119        );
22120    }
22121
22122    #[test]
22123    fn route_edit_keywords_to_sonnet() {
22124        for kw in &[
22125            "edit the file",
22126            "fix the bug",
22127            "update the config",
22128            "remove dead code",
22129            "create a new module",
22130        ] {
22131            let (tier, _) = classify_task(kw);
22132            assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
22133        }
22134    }
22135
22136    #[test]
22137    fn route_architecture_keywords_to_opus() {
22138        for kw in &[
22139            "design the API",
22140            "architecture review",
22141            "plan the migration",
22142            "analyze the system",
22143            "evaluate trade-offs",
22144        ] {
22145            let (tier, _) = classify_task(kw);
22146            assert_eq!(tier, "opus", "expected opus for {:?}", kw);
22147        }
22148    }
22149
22150    #[test]
22151    fn route_architecture_beats_edit() {
22152        // "design and implement" — architecture signal wins (checked first)
22153        let (tier, _) = classify_task("design and implement the new auth service");
22154        assert_eq!(tier, "opus");
22155    }
22156
22157    #[test]
22158    fn cli_accepts_global_compact_flag() {
22159        let cli = parse_cli(["tsift", "--compact", "status"]);
22160        assert!(cli.compact);
22161        assert!(matches!(cli.command, Some(Commands::Status { .. })));
22162    }
22163
22164    #[test]
22165    fn summarize_diff_scope_matches_relative_directory() {
22166        let root = Path::new("/repo");
22167        let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
22168
22169        assert!(summarize_diff_matches_scope(
22170            Path::new("/repo/src/feature/main.rs"),
22171            &extract_scope
22172        ));
22173        assert!(!summarize_diff_matches_scope(
22174            Path::new("/repo/src/other/main.rs"),
22175            &extract_scope
22176        ));
22177    }
22178
22179    #[test]
22180    fn summarize_diff_scope_matches_relative_file() {
22181        let root = Path::new("/repo");
22182        let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
22183
22184        assert!(summarize_diff_matches_scope(
22185            Path::new("/repo/src/feature/main.rs"),
22186            &extract_scope
22187        ));
22188        assert!(!summarize_diff_matches_scope(
22189            Path::new("/repo/src/feature/lib.rs"),
22190            &extract_scope
22191        ));
22192    }
22193
22194    #[test]
22195    fn summarize_extract_scope_walks_relative_paths_from_root() {
22196        let dir = tempfile::tempdir().unwrap();
22197        let source_dir = dir.path().join("src");
22198        std::fs::create_dir_all(&source_dir).unwrap();
22199        let main_rs = source_dir.join("main.rs");
22200        std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
22201
22202        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
22203        let files = collect_source_files(&extract_scope).unwrap();
22204
22205        assert_eq!(files, vec![main_rs]);
22206    }
22207
22208    #[test]
22209    fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
22210        let dir = tempfile::tempdir().unwrap();
22211        let nested = dir.path().join("src/nested");
22212        std::fs::create_dir_all(&nested).unwrap();
22213        std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
22214        let nested_file = nested.join("main.rs");
22215        std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
22216
22217        let extract_base = resolve_extract_base(&nested).unwrap();
22218        let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
22219        let files = collect_source_files(&extract_scope).unwrap();
22220
22221        assert_eq!(extract_scope, nested);
22222        assert_eq!(files, vec![nested_file]);
22223    }
22224
22225    #[test]
22226    fn summarize_extract_base_uses_parent_of_file_path() {
22227        let dir = tempfile::tempdir().unwrap();
22228        let nested = dir.path().join("src/nested");
22229        std::fs::create_dir_all(&nested).unwrap();
22230        let file_path = nested.join("main.rs");
22231        std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
22232
22233        let extract_base = resolve_extract_base(&file_path).unwrap();
22234
22235        assert_eq!(extract_base, nested);
22236    }
22237
22238    #[test]
22239    fn summarize_extract_scope_normalizes_dotdot_segments() {
22240        let dir = tempfile::tempdir().unwrap();
22241        let source_dir = dir.path().join("src");
22242        std::fs::create_dir_all(&source_dir).unwrap();
22243
22244        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
22245
22246        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22247        assert!(summarize_diff_matches_scope(
22248            &source_dir.join("main.rs"),
22249            &extract_scope
22250        ));
22251    }
22252
22253    #[cfg(unix)]
22254    #[test]
22255    fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
22256        use std::os::unix::fs::symlink;
22257
22258        let dir = tempfile::tempdir().unwrap();
22259        let real_root = dir.path().join("real");
22260        let source_dir = real_root.join("src");
22261        std::fs::create_dir_all(&source_dir).unwrap();
22262        let symlink_scope = dir.path().join("scope-link");
22263        symlink(&source_dir, &symlink_scope).unwrap();
22264
22265        let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
22266
22267        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22268        assert!(summarize_diff_matches_scope(
22269            &source_dir.join("lib.rs"),
22270            &extract_scope
22271        ));
22272    }
22273
22274    #[test]
22275    fn summarize_diff_extract_includes_untracked_files() {
22276        let dir = tempfile::tempdir().unwrap();
22277        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22278        init_git_repo(dir.path());
22279
22280        let source_dir = dir.path().join("src");
22281        std::fs::create_dir_all(&source_dir).unwrap();
22282        let new_file = source_dir.join("new.rs");
22283        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22284
22285        let files = summarize::git_changed_files(dir.path()).unwrap();
22286
22287        assert_eq!(files.existing, vec![new_file]);
22288        assert!(files.deleted.is_empty());
22289    }
22290
22291    #[test]
22292    fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
22293        let dir = tempfile::tempdir().unwrap();
22294        let status = std::process::Command::new("git")
22295            .args(["init"])
22296            .current_dir(dir.path())
22297            .status()
22298            .unwrap();
22299        assert!(status.success(), "git init failed");
22300
22301        let source_dir = dir.path().join("src");
22302        std::fs::create_dir_all(&source_dir).unwrap();
22303        let new_file = source_dir.join("new.rs");
22304        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22305
22306        let files = summarize::git_changed_files(dir.path()).unwrap();
22307
22308        assert_eq!(files.existing, vec![new_file]);
22309        assert!(files.deleted.is_empty());
22310    }
22311
22312    #[test]
22313    fn summarize_diff_extract_tracks_deleted_files() {
22314        let dir = tempfile::tempdir().unwrap();
22315        let source_dir = dir.path().join("src");
22316        std::fs::create_dir_all(&source_dir).unwrap();
22317        let deleted_file = source_dir.join("gone.rs");
22318        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22319        init_git_repo(dir.path());
22320
22321        std::fs::remove_file(&deleted_file).unwrap();
22322
22323        let files = summarize::git_changed_files(dir.path()).unwrap();
22324
22325        assert!(files.existing.is_empty());
22326        assert_eq!(files.deleted, vec![deleted_file]);
22327    }
22328
22329    #[test]
22330    fn summarize_diff_extract_tracks_git_renames() {
22331        let dir = tempfile::tempdir().unwrap();
22332        let source_dir = dir.path().join("src");
22333        std::fs::create_dir_all(&source_dir).unwrap();
22334        let old_file = source_dir.join("old.rs");
22335        let new_file = source_dir.join("new.rs");
22336        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22337        init_git_repo(dir.path());
22338
22339        let status = std::process::Command::new("git")
22340            .args(["mv", "src/old.rs", "src/new.rs"])
22341            .current_dir(dir.path())
22342            .status()
22343            .unwrap();
22344        assert!(status.success(), "git mv failed");
22345
22346        let files = summarize::git_changed_files(dir.path()).unwrap();
22347
22348        assert_eq!(files.existing, vec![new_file]);
22349        assert_eq!(files.deleted, vec![old_file]);
22350    }
22351
22352    #[test]
22353    fn summarize_diff_extract_deletes_removed_summary_rows() {
22354        let dir = tempfile::tempdir().unwrap();
22355        let source_dir = dir.path().join("src");
22356        std::fs::create_dir_all(&source_dir).unwrap();
22357        let deleted_file = source_dir.join("gone.rs");
22358        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22359        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22360        init_git_repo(dir.path());
22361
22362        let summary_db =
22363            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22364        summary_db
22365            .insert(&summarize::Summary {
22366                id: 0,
22367                symbol_name: "stale".to_string(),
22368                file_path: "src/gone.rs".to_string(),
22369                content_hash: "hash1".to_string(),
22370                summary: "stale summary".to_string(),
22371                entities: None,
22372                relationships: None,
22373                concept_labels: None,
22374                extracted_at: "1700000000".to_string(),
22375                model: "test".to_string(),
22376                tokens_input: Some(100),
22377                tokens_output: Some(50),
22378            })
22379            .unwrap();
22380
22381        std::fs::remove_file(&deleted_file).unwrap();
22382
22383        cmd_summarize(
22384            None,
22385            None,
22386            Some(PathBuf::from("src")),
22387            true,
22388            false,
22389            dir.path(),
22390            false,
22391            true,
22392            false,
22393            false,
22394            false,
22395            None,
22396        )
22397        .unwrap();
22398
22399        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22400    }
22401
22402    #[test]
22403    fn summarize_diff_extract_deletes_renamed_summary_rows() {
22404        let dir = tempfile::tempdir().unwrap();
22405        let source_dir = dir.path().join("src");
22406        std::fs::create_dir_all(&source_dir).unwrap();
22407        let old_file = source_dir.join("old.rs");
22408        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22409        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22410        init_git_repo(dir.path());
22411
22412        let summary_db =
22413            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22414        summary_db
22415            .insert(&summarize::Summary {
22416                id: 0,
22417                symbol_name: "stale".to_string(),
22418                file_path: "src/old.rs".to_string(),
22419                content_hash: "hash1".to_string(),
22420                summary: "stale summary".to_string(),
22421                entities: None,
22422                relationships: None,
22423                concept_labels: None,
22424                extracted_at: "1700000000".to_string(),
22425                model: "test".to_string(),
22426                tokens_input: Some(100),
22427                tokens_output: Some(50),
22428            })
22429            .unwrap();
22430
22431        let status = std::process::Command::new("git")
22432            .args(["mv", "src/old.rs", "src/new.rs"])
22433            .current_dir(dir.path())
22434            .status()
22435            .unwrap();
22436        assert!(status.success(), "git mv failed");
22437
22438        cmd_summarize(
22439            None,
22440            None,
22441            Some(PathBuf::from("src")),
22442            true,
22443            false,
22444            dir.path(),
22445            false,
22446            true,
22447            false,
22448            false,
22449            false,
22450            None,
22451        )
22452        .unwrap();
22453
22454        assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
22455    }
22456
22457    #[test]
22458    fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
22459        let dir = tempfile::tempdir().unwrap();
22460        let source_dir = dir.path().join("src");
22461        std::fs::create_dir_all(&source_dir).unwrap();
22462        let deleted_file = source_dir.join("gone.rs");
22463        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22464
22465        let summary_db =
22466            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22467        summary_db
22468            .insert(&summarize::Summary {
22469                id: 0,
22470                symbol_name: "stale".to_string(),
22471                file_path: "src/gone.rs".to_string(),
22472                content_hash: "hash1".to_string(),
22473                summary: "stale summary".to_string(),
22474                entities: None,
22475                relationships: None,
22476                concept_labels: None,
22477                extracted_at: "1700000000".to_string(),
22478                model: "test".to_string(),
22479                tokens_input: Some(100),
22480                tokens_output: Some(50),
22481            })
22482            .unwrap();
22483
22484        std::fs::remove_file(&deleted_file).unwrap();
22485
22486        cmd_summarize(
22487            None,
22488            None,
22489            Some(PathBuf::from("src")),
22490            false,
22491            false,
22492            dir.path(),
22493            false,
22494            true,
22495            false,
22496            false,
22497            false,
22498            None,
22499        )
22500        .unwrap();
22501
22502        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22503    }
22504
22505    #[test]
22506    fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
22507        let dir = tempfile::tempdir().unwrap();
22508        let source_dir = dir.path().join("src");
22509        std::fs::create_dir_all(&source_dir).unwrap();
22510        let file = source_dir.join("lib.rs");
22511        std::fs::write(&file, "fn helper() {}\n").unwrap();
22512
22513        let content = std::fs::read(&file).unwrap();
22514        let summary_db =
22515            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22516        summary_db
22517            .insert(&summarize::Summary {
22518                id: 0,
22519                symbol_name: "lib.rs".to_string(),
22520                file_path: "src/lib.rs".to_string(),
22521                content_hash: summarize::content_hash(&content),
22522                summary: "cached summary".to_string(),
22523                entities: None,
22524                relationships: None,
22525                concept_labels: None,
22526                extracted_at: "1700000000".to_string(),
22527                model: "test".to_string(),
22528                tokens_input: Some(100),
22529                tokens_output: Some(50),
22530            })
22531            .unwrap();
22532        drop(summary_db);
22533
22534        let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
22535        let _lock = hold_writer_lock(&lock_path);
22536
22537        let err = cmd_summarize(
22538            None,
22539            None,
22540            Some(PathBuf::from("src")),
22541            false,
22542            false,
22543            dir.path(),
22544            false,
22545            true,
22546            false,
22547            false,
22548            false,
22549            None,
22550        )
22551        .unwrap_err();
22552        let message = err.to_string();
22553
22554        assert!(message.contains("another tsift summarize extractor is already active"));
22555        assert!(message.contains("tsift summarize --extract"));
22556    }
22557
22558    #[test]
22559    fn summarize_stats_fails_closed_when_cache_missing() {
22560        let dir = tempfile::tempdir().unwrap();
22561        let err = cmd_summarize(
22562            None,
22563            None,
22564            None,
22565            false,
22566            true,
22567            dir.path(),
22568            false,
22569            false,
22570            false,
22571            false,
22572            false,
22573            None,
22574        )
22575        .unwrap_err();
22576
22577        assert!(
22578            err.to_string().contains("no summaries.db found"),
22579            "got: {err}"
22580        );
22581        assert!(!dir.path().join(".tsift/summaries.db").exists());
22582    }
22583
22584    #[test]
22585    fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22586        let dir = tempfile::tempdir().unwrap();
22587        let summary_db =
22588            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22589        summary_db
22590            .insert(&summarize::Summary {
22591                id: 0,
22592                symbol_name: "alpha_helper".to_string(),
22593                file_path: "src/lib.rs".to_string(),
22594                content_hash: "hash1".to_string(),
22595                summary: "cached summary".to_string(),
22596                entities: None,
22597                relationships: None,
22598                concept_labels: None,
22599                extracted_at: "1700000000".to_string(),
22600                model: "claude-haiku-4-5-20251001".to_string(),
22601                tokens_input: Some(100),
22602                tokens_output: Some(40),
22603            })
22604            .unwrap();
22605        drop(summary_db);
22606        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22607
22608        let result = cmd_summarize(
22609            None,
22610            None,
22611            None,
22612            false,
22613            true,
22614            dir.path(),
22615            false,
22616            false,
22617            false,
22618            false,
22619            false,
22620            None,
22621        );
22622
22623        assert!(result.is_ok());
22624    }
22625
22626    #[test]
22627    fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22628        let dir = tempfile::tempdir().unwrap();
22629        let summary_db =
22630            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22631        summary_db
22632            .insert(&summarize::Summary {
22633                id: 0,
22634                symbol_name: "alpha_helper".to_string(),
22635                file_path: "src/lib.rs".to_string(),
22636                content_hash: "hash1".to_string(),
22637                summary: "cached summary".to_string(),
22638                entities: None,
22639                relationships: None,
22640                concept_labels: None,
22641                extracted_at: "1700000000".to_string(),
22642                model: "claude-haiku-4-5-20251001".to_string(),
22643                tokens_input: Some(100),
22644                tokens_output: Some(40),
22645            })
22646            .unwrap();
22647        drop(summary_db);
22648        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22649
22650        let result = cmd_summarize(
22651            Some("alpha_helper".to_string()),
22652            None,
22653            None,
22654            false,
22655            false,
22656            dir.path(),
22657            false,
22658            true,
22659            false,
22660            false,
22661            false,
22662            None,
22663        );
22664
22665        assert!(result.is_ok());
22666    }
22667
22668    #[test]
22669    fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
22670        let dir = tempfile::tempdir().unwrap();
22671        let nested = dir.path().join("src/nested");
22672        std::fs::create_dir_all(&nested).unwrap();
22673
22674        let summary_db =
22675            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22676        summary_db
22677            .insert(&summarize::Summary {
22678                id: 0,
22679                symbol_name: "alpha_helper".to_string(),
22680                file_path: "src/lib.rs".to_string(),
22681                content_hash: "hash1".to_string(),
22682                summary: "cached summary".to_string(),
22683                entities: None,
22684                relationships: None,
22685                concept_labels: None,
22686                extracted_at: "1700000000".to_string(),
22687                model: "claude-haiku-4-5-20251001".to_string(),
22688                tokens_input: Some(100),
22689                tokens_output: Some(40),
22690            })
22691            .unwrap();
22692
22693        let result = cmd_summarize(
22694            Some("alpha_helper".to_string()),
22695            None,
22696            None,
22697            false,
22698            false,
22699            &nested,
22700            false,
22701            true,
22702            false,
22703            false,
22704            false,
22705            None,
22706        );
22707
22708        assert!(result.is_ok());
22709        assert!(!nested.join(".tsift/summaries.db").exists());
22710    }
22711
22712    #[test]
22713    fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22714        let dir = tempfile::tempdir().unwrap();
22715        std::fs::write(
22716            dir.path().join(".gitmodules"),
22717            r#"[submodule "src/alpha"]
22718	path = src/alpha
22719	url = https://example.com/alpha
22720[submodule "src/beta"]
22721	path = src/beta
22722	url = https://example.com/beta
22723"#,
22724        )
22725        .unwrap();
22726
22727        let alpha_root = dir.path().join("src/alpha");
22728        let beta_root = dir.path().join("src/beta");
22729        std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22730        std::fs::create_dir_all(beta_root.join("src")).unwrap();
22731        std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22732        std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22733        std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22734        let beta_file = beta_root.join("src/lib.rs");
22735        std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22736        std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22737        std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22738
22739        let context = find_symbols_db_for_file(dir.path(), &beta_file)
22740            .unwrap()
22741            .expect("expected matching scoped index");
22742
22743        assert_eq!(
22744            context.db_path,
22745            dir.path().join(".tsift/indexes/beta/index.db")
22746        );
22747        assert_eq!(context.source_root, beta_root);
22748    }
22749
22750    // --- apply_edit_op ---
22751
22752    fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22753        EditOp {
22754            file: PathBuf::from("dummy.txt"),
22755            old: old.to_string(),
22756            new: new.to_string(),
22757            replace_all,
22758        }
22759    }
22760
22761    #[test]
22762    fn edit_replaces_single_occurrence() {
22763        let content = "hello world";
22764        let op = make_op("world", "rust", false);
22765        let (result, count) = apply_edit_op(content, &op).unwrap();
22766        assert_eq!(result, "hello rust");
22767        assert_eq!(count, 1);
22768    }
22769
22770    #[test]
22771    fn edit_replace_all_replaces_every_occurrence() {
22772        let content = "foo foo foo";
22773        let op = make_op("foo", "bar", true);
22774        let (result, count) = apply_edit_op(content, &op).unwrap();
22775        assert_eq!(result, "bar bar bar");
22776        assert_eq!(count, 3);
22777    }
22778
22779    #[test]
22780    fn edit_fails_when_old_not_found() {
22781        let content = "hello world";
22782        let op = make_op("missing", "x", false);
22783        assert!(apply_edit_op(content, &op).is_err());
22784    }
22785
22786    #[test]
22787    fn edit_fails_when_ambiguous_without_replace_all() {
22788        let content = "foo foo";
22789        let op = make_op("foo", "bar", false);
22790        let err = apply_edit_op(content, &op).unwrap_err();
22791        assert!(err.to_string().contains("2 times"), "got: {}", err);
22792    }
22793
22794    #[test]
22795    fn edit_fails_when_old_equals_new() {
22796        let content = "hello";
22797        let op = make_op("hello", "hello", false);
22798        assert!(apply_edit_op(content, &op).is_err());
22799    }
22800
22801    #[test]
22802    fn edit_batch_rolls_back_when_later_swap_fails() {
22803        let dir = tempfile::tempdir().unwrap();
22804        let alpha = dir.path().join("alpha.txt");
22805        let beta = dir.path().join("beta.txt");
22806        fs::write(&alpha, "alpha old\n").unwrap();
22807        fs::write(&beta, "beta old\n").unwrap();
22808
22809        let batch = EditBatch {
22810            edits: vec![
22811                EditOp {
22812                    file: alpha.clone(),
22813                    old: "old".to_string(),
22814                    new: "new".to_string(),
22815                    replace_all: false,
22816                },
22817                EditOp {
22818                    file: beta.clone(),
22819                    old: "old".to_string(),
22820                    new: "new".to_string(),
22821                    replace_all: false,
22822                },
22823            ],
22824        };
22825
22826        let plan = build_edit_plan(&batch).unwrap();
22827        let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22828            if commit_index == 1 {
22829                bail!("simulated swap failure");
22830            }
22831            Ok(())
22832        }) {
22833            Ok(_) => panic!("expected simulated swap failure"),
22834            Err(err) => err,
22835        };
22836
22837        assert!(err.to_string().contains("simulated swap failure"));
22838        assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22839        assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22840    }
22841
22842    // --- SQL introspection ---
22843
22844    fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22845        let tmp = tempfile::NamedTempFile::new().unwrap();
22846        let conn = Connection::open(tmp.path()).unwrap();
22847        conn.execute_batch(
22848            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22849             INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22850             INSERT INTO users VALUES (2, 'Bob', NULL);
22851             CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22852                 FOREIGN KEY(user_id) REFERENCES users(id));
22853             INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22854             INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22855             INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22856        ).unwrap();
22857        (tmp, conn)
22858    }
22859
22860    // --- rewrite_command ---
22861
22862    #[test]
22863    fn rewrite_rg_simple_pattern() {
22864        let result = rewrite_command("rg authenticate");
22865        assert_eq!(
22866            result,
22867            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22868        );
22869    }
22870
22871    #[test]
22872    fn rewrite_rg_with_path() {
22873        let result = rewrite_command("rg authenticate src/");
22874        assert_eq!(
22875            result,
22876            Some(
22877                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22878                    .to_string()
22879            )
22880        );
22881    }
22882
22883    #[test]
22884    fn rewrite_rg_with_flags_ignored() {
22885        let result = rewrite_command("rg -i authenticate src/");
22886        assert_eq!(
22887            result,
22888            Some(
22889                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22890                    .to_string()
22891            )
22892        );
22893    }
22894
22895    #[test]
22896    fn rewrite_rg_with_type_flag() {
22897        // -t rs takes a value, should be skipped; pattern is next positional
22898        let result = rewrite_command("rg -t rs authenticate");
22899        assert_eq!(
22900            result,
22901            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
22902        );
22903    }
22904
22905    #[test]
22906    fn rewrite_rg_pipe_passthrough() {
22907        // Pipe chains can't be translated — pass through
22908        let result = rewrite_command("rg authenticate | head -5");
22909        assert_eq!(result, None);
22910    }
22911
22912    #[test]
22913    fn rewrite_rg_files_passthrough() {
22914        let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22915        assert_eq!(result, None);
22916    }
22917
22918    #[test]
22919    fn rewrite_find_passthrough() {
22920        let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22921        assert_eq!(result, None);
22922    }
22923
22924    #[test]
22925    fn rewrite_grep_recursive() {
22926        let result = rewrite_command("grep -r authenticate src/");
22927        assert_eq!(
22928            result,
22929            Some(
22930                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22931                    .to_string()
22932            )
22933        );
22934    }
22935
22936    #[test]
22937    fn rewrite_grep_non_recursive_passthrough() {
22938        let result = rewrite_command("grep authenticate file.txt");
22939        assert_eq!(result, None);
22940    }
22941
22942    #[test]
22943    fn rewrite_tsift_passthrough() {
22944        let result = rewrite_command("tsift search \"foo\"");
22945        assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22946    }
22947
22948    #[test]
22949    fn rewrite_run_tsift_search_disables_timeout_by_default() {
22950        let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22951        assert_eq!(
22952            result,
22953            "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22954        );
22955    }
22956
22957    #[test]
22958    fn rewrite_run_preserves_explicit_search_timeout() {
22959        let result = effective_rewrite_run_command(
22960            "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22961        );
22962        assert_eq!(
22963            result,
22964            "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22965        );
22966    }
22967
22968    #[test]
22969    fn rewrite_unrelated_passthrough() {
22970        let result = rewrite_command("echo cargo build");
22971        assert_eq!(result, None);
22972    }
22973
22974    #[test]
22975    fn rewrite_rg_quoted_pattern() {
22976        let result = rewrite_command("rg \"fn main\"");
22977        assert_eq!(
22978            result,
22979            Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22980        );
22981    }
22982
22983    #[test]
22984    fn rewrite_git_diff_to_diff_digest() {
22985        let result = rewrite_command("git diff");
22986        assert_eq!(result, Some("tsift diff-digest .".to_string()));
22987    }
22988
22989    #[test]
22990    fn rewrite_git_diff_cached_to_diff_digest() {
22991        let result = rewrite_command("git diff --cached");
22992        assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22993    }
22994
22995    #[test]
22996    fn rewrite_git_diff_with_path_to_diff_digest() {
22997        let result = rewrite_command("git diff -- src/");
22998        assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22999    }
23000
23001    #[test]
23002    fn rewrite_git_diff_with_revision_passthrough() {
23003        let result = rewrite_command("git diff HEAD~1");
23004        assert_eq!(result, None);
23005    }
23006
23007    #[test]
23008    fn rewrite_git_show_to_revision_diff_digest() {
23009        let result = rewrite_command("git show HEAD~1");
23010        assert_eq!(
23011            result,
23012            Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
23013        );
23014    }
23015
23016    #[test]
23017    fn rewrite_git_log_patch_history_to_revision_diff_digest() {
23018        let result = rewrite_command("git log -p -1 HEAD~2");
23019        assert_eq!(
23020            result,
23021            Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
23022        );
23023    }
23024
23025    #[test]
23026    fn rewrite_cat_long_agent_doc_session_to_session_digest() {
23027        let dir = tempfile::tempdir().unwrap();
23028        let session = dir.path().join("tsift.md");
23029        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23030        for index in 0..90 {
23031            body.push_str(&format!("❯ prompt {index}?\n"));
23032        }
23033        fs::write(&session, body).unwrap();
23034
23035        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23036        assert_eq!(
23037            result,
23038            Some(format!(
23039                "tsift session-digest --path {} --input {} --source markdown",
23040                shell_quote(&resolve_digest_context_path(&session)),
23041                shell_quote(session.to_str().unwrap())
23042            ))
23043        );
23044    }
23045
23046    #[test]
23047    fn rewrite_head_long_claude_jsonl_to_session_digest() {
23048        let dir = tempfile::tempdir().unwrap();
23049        let session = dir.path().join("session.jsonl");
23050        let line =
23051            r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
23052        let body = std::iter::repeat_n(line, 120)
23053            .collect::<Vec<_>>()
23054            .join("\n");
23055        fs::write(&session, format!("{body}\n")).unwrap();
23056
23057        let result = rewrite_command(&format!(
23058            "head -n 120 {}",
23059            shell_quote(session.to_str().unwrap())
23060        ));
23061        assert_eq!(
23062            result,
23063            Some(format!(
23064                "tsift session-digest --path {} --input {} --source claude-jsonl",
23065                shell_quote(&resolve_digest_context_path(&session)),
23066                shell_quote(session.to_str().unwrap())
23067            ))
23068        );
23069    }
23070
23071    #[test]
23072    fn rewrite_head_long_codex_jsonl_to_session_digest() {
23073        let dir = tempfile::tempdir().unwrap();
23074        let session = dir.path().join("codex.jsonl");
23075        let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
23076        let body = std::iter::repeat_n(line, 120)
23077            .collect::<Vec<_>>()
23078            .join("\n");
23079        fs::write(&session, format!("{body}\n")).unwrap();
23080
23081        let result = rewrite_command(&format!(
23082            "head -n 120 {}",
23083            shell_quote(session.to_str().unwrap())
23084        ));
23085        assert_eq!(
23086            result,
23087            Some(format!(
23088                "tsift session-digest --path {} --input {} --source codex-jsonl",
23089                shell_quote(&resolve_digest_context_path(&session)),
23090                shell_quote(session.to_str().unwrap())
23091            ))
23092        );
23093    }
23094
23095    #[test]
23096    fn rewrite_small_transcript_window_passthrough() {
23097        let dir = tempfile::tempdir().unwrap();
23098        let session = dir.path().join("session.jsonl");
23099        let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
23100        let body = std::iter::repeat_n(line, 120)
23101            .collect::<Vec<_>>()
23102            .join("\n");
23103        fs::write(&session, format!("{body}\n")).unwrap();
23104
23105        let result = rewrite_command(&format!(
23106            "tail -n 20 {}",
23107            shell_quote(session.to_str().unwrap())
23108        ));
23109        assert_eq!(result, None);
23110    }
23111
23112    #[test]
23113    fn rewrite_sed_large_agent_doc_range_to_session_digest() {
23114        let dir = tempfile::tempdir().unwrap();
23115        let session = dir.path().join("tsift.md");
23116        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23117        for index in 0..120 {
23118            body.push_str(&format!("### Re: topic {index}\n"));
23119        }
23120        fs::write(&session, body).unwrap();
23121
23122        let result = rewrite_command(&format!(
23123            "sed -n '1,120p' {}",
23124            shell_quote(session.to_str().unwrap())
23125        ));
23126        assert_eq!(
23127            result,
23128            Some(format!(
23129                "tsift session-digest --path {} --input {} --source markdown",
23130                shell_quote(&resolve_digest_context_path(&session)),
23131                shell_quote(session.to_str().unwrap())
23132            ))
23133        );
23134    }
23135
23136    #[test]
23137    fn rewrite_cat_large_agent_doc_log_to_session_digest() {
23138        let dir = tempfile::tempdir().unwrap();
23139        let session = dir.path().join("tsift.log");
23140        let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
23141        let body = std::iter::repeat_n(line, 120)
23142            .collect::<Vec<_>>()
23143            .join("\n");
23144        fs::write(&session, format!("{body}\n")).unwrap();
23145
23146        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23147        assert_eq!(
23148            result,
23149            Some(format!(
23150                "tsift session-digest --path {} --input {} --source agent-doc-log",
23151                shell_quote(&resolve_digest_context_path(&session)),
23152                shell_quote(session.to_str().unwrap())
23153            ))
23154        );
23155    }
23156
23157    #[test]
23158    fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
23159        let dir = tempfile::tempdir().unwrap();
23160        fs::write(
23161            dir.path().join(".gitmodules"),
23162            r#"[submodule "src/tsift"]
23163	path = src/tsift
23164	url = https://example.com/tsift
23165"#,
23166        )
23167        .unwrap();
23168        let submodule = dir.path().join("src/tsift");
23169        fs::create_dir_all(submodule.join("tasks")).unwrap();
23170        fs::write(
23171            submodule.join(".git"),
23172            "gitdir: ../../.git/modules/src/tsift\n",
23173        )
23174        .unwrap();
23175        let session = submodule.join("tasks/plan.md");
23176        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23177        for index in 0..90 {
23178            body.push_str(&format!("❯ prompt {index}?\n"));
23179        }
23180        fs::write(&session, body).unwrap();
23181
23182        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23183
23184        assert_eq!(
23185            result,
23186            Some(format!(
23187                "tsift session-digest --path {} --input {} --source markdown",
23188                shell_quote(submodule.to_str().unwrap()),
23189                shell_quote(session.to_str().unwrap())
23190            ))
23191        );
23192    }
23193
23194    #[test]
23195    fn rewrite_regular_markdown_read_passthrough() {
23196        let dir = tempfile::tempdir().unwrap();
23197        let readme = dir.path().join("README.md");
23198        let body = std::iter::repeat_n("plain markdown", 120)
23199            .collect::<Vec<_>>()
23200            .join("\n");
23201        fs::write(&readme, format!("{body}\n")).unwrap();
23202
23203        let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
23204        assert_eq!(result, None);
23205    }
23206
23207    #[test]
23208    fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
23209        let dir = tempfile::tempdir().unwrap();
23210        write_empty_root_index(dir.path());
23211        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23212
23213        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23214
23215        assert_eq!(
23216            result,
23217            Some(format!(
23218                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
23219                shell_quote(&dir.path().to_string_lossy())
23220            ))
23221        );
23222    }
23223
23224    #[test]
23225    fn rewrite_head_small_source_window_passthrough() {
23226        let dir = tempfile::tempdir().unwrap();
23227        write_empty_root_index(dir.path());
23228        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23229
23230        let result = rewrite_command(&format!(
23231            "head -n 20 {}",
23232            shell_quote(source.to_str().unwrap())
23233        ));
23234
23235        assert_eq!(result, None);
23236    }
23237
23238    #[test]
23239    fn rewrite_sed_large_source_range_to_source_read() {
23240        let dir = tempfile::tempdir().unwrap();
23241        write_empty_root_index(dir.path());
23242        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23243
23244        let result = rewrite_command(&format!(
23245            "sed -n '40,160p' {}",
23246            shell_quote(source.to_str().unwrap())
23247        ));
23248
23249        assert_eq!(
23250            result,
23251            Some(format!(
23252                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
23253                shell_quote(&dir.path().to_string_lossy())
23254            ))
23255        );
23256    }
23257
23258    #[test]
23259    fn rewrite_tail_large_source_window_preserves_tail_anchor() {
23260        let dir = tempfile::tempdir().unwrap();
23261        write_empty_root_index(dir.path());
23262        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23263
23264        let result = rewrite_command(&format!(
23265            "tail -n 120 {}",
23266            shell_quote(source.to_str().unwrap())
23267        ));
23268
23269        assert_eq!(
23270            result,
23271            Some(format!(
23272                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
23273                shell_quote(&dir.path().to_string_lossy())
23274            ))
23275        );
23276    }
23277
23278    #[test]
23279    fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
23280        let dir = tempfile::tempdir().unwrap();
23281        write_empty_root_index(dir.path());
23282        let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
23283
23284        let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
23285
23286        assert_eq!(result, None);
23287    }
23288
23289    #[test]
23290    fn rewrite_large_source_read_passthrough_without_index() {
23291        let dir = tempfile::tempdir().unwrap();
23292        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23293
23294        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23295
23296        assert_eq!(result, None);
23297    }
23298
23299    #[test]
23300    fn rewrite_cargo_test_to_digest_runner() {
23301        let result = rewrite_command("cargo test --lib");
23302        assert_eq!(
23303            result,
23304            Some(
23305                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
23306            )
23307        );
23308    }
23309
23310    #[test]
23311    fn rewrite_pytest_to_digest_runner() {
23312        let result = rewrite_command("pytest -q tests/test_cli.py");
23313        assert_eq!(
23314            result,
23315            Some(
23316                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
23317            )
23318        );
23319    }
23320
23321    #[test]
23322    fn rewrite_python_m_pytest_to_digest_runner() {
23323        let result = rewrite_command("python -m pytest tests/test_cli.py");
23324        assert_eq!(
23325            result,
23326            Some(
23327                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
23328            )
23329        );
23330    }
23331
23332    #[test]
23333    fn rewrite_cargo_build_to_log_digest_runner() {
23334        let result = rewrite_command("cargo build --release");
23335        assert_eq!(
23336            result,
23337            Some(
23338                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
23339            )
23340        );
23341    }
23342
23343    #[test]
23344    fn rewrite_cargo_install_to_log_digest_runner() {
23345        let result = rewrite_command("cargo install --path . --force");
23346        assert_eq!(
23347            result,
23348            Some(
23349                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
23350            )
23351        );
23352    }
23353
23354    #[test]
23355    fn rewrite_metacharacter_command_passthrough() {
23356        let result = rewrite_command("cargo test | head");
23357        assert_eq!(result, None);
23358    }
23359
23360    #[test]
23361    fn rewrite_output_cap_detects_search_even_with_global_flag() {
23362        let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
23363        assert_eq!(cap.max_lines, 50);
23364        assert_eq!(cap.strip_prefix, Some("Strategy:"));
23365    }
23366
23367    #[test]
23368    fn rewrite_output_cap_skips_structured_output() {
23369        assert!(rewrite_output_cap("tsift search foo --json").is_none());
23370        assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
23371        assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
23372    }
23373
23374    #[test]
23375    fn rewrite_output_format_forwards_envelope_to_digest_runner() {
23376        let command = rewrite_command("cargo test --lib").expect("rewrite");
23377        let forwarded = apply_rewrite_output_format(
23378            &command,
23379            OutputFormat {
23380                json_output: true,
23381                compact: false,
23382                pretty: false,
23383                terse: false,
23384                ultra_terse: false,
23385                schema: false,
23386                envelope: true,
23387            },
23388        );
23389        assert_eq!(
23390            forwarded,
23391            "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
23392        );
23393    }
23394
23395    #[test]
23396    fn rewrite_output_format_forwards_json_when_requested() {
23397        let command = rewrite_command("cargo build --release").expect("rewrite");
23398        let forwarded = apply_rewrite_output_format(
23399            &command,
23400            OutputFormat {
23401                json_output: true,
23402                compact: false,
23403                pretty: true,
23404                terse: false,
23405                ultra_terse: false,
23406                schema: false,
23407                envelope: false,
23408            },
23409        );
23410        assert_eq!(
23411            forwarded,
23412            "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
23413        );
23414    }
23415
23416    #[test]
23417    fn output_cap_strips_search_header_and_truncates() {
23418        let capped = apply_output_cap(
23419            b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
23420            OutputCap {
23421                max_lines: 2,
23422                strip_prefix: Some("Strategy:"),
23423            },
23424        );
23425        assert_eq!(
23426            capped,
23427            "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
23428        );
23429    }
23430
23431    #[test]
23432    fn sql_schema_overview_lists_tables() {
23433        let (_tmp, conn) = setup_test_db();
23434        let tables = schema_overview(&conn).unwrap();
23435        let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
23436        assert_eq!(names, &["posts", "users"]);
23437    }
23438
23439    #[test]
23440    fn sql_schema_overview_row_counts() {
23441        let (_tmp, conn) = setup_test_db();
23442        let tables = schema_overview(&conn).unwrap();
23443        let users = tables.iter().find(|t| t.name == "users").unwrap();
23444        let posts = tables.iter().find(|t| t.name == "posts").unwrap();
23445        assert_eq!(users.row_count, 2);
23446        assert_eq!(posts.row_count, 3);
23447    }
23448
23449    #[test]
23450    fn sql_table_columns_metadata() {
23451        let (_tmp, conn) = setup_test_db();
23452        let cols = table_columns(&conn, "users").unwrap();
23453        assert_eq!(cols.len(), 3);
23454        assert_eq!(cols[0].name, "id");
23455        assert!(cols[0].pk);
23456        assert_eq!(cols[1].name, "name");
23457        assert!(cols[1].notnull);
23458        assert_eq!(cols[2].name, "email");
23459        assert!(!cols[2].notnull);
23460    }
23461
23462    #[test]
23463    fn sql_execute_query_returns_rows() {
23464        let (_tmp, conn) = setup_test_db();
23465        let (columns, rows) =
23466            execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
23467        assert_eq!(columns, &["name", "email"]);
23468        assert_eq!(rows.len(), 2);
23469        assert_eq!(rows[0][0], serde_json::json!("Alice"));
23470        assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
23471        assert_eq!(rows[1][1], serde_json::Value::Null);
23472    }
23473
23474    #[test]
23475    fn sql_execute_query_aggregate() {
23476        let (_tmp, conn) = setup_test_db();
23477        let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
23478        assert_eq!(columns, &["cnt"]);
23479        assert_eq!(rows[0][0], serde_json::json!(3));
23480    }
23481
23482    #[test]
23483    fn sql_execute_query_join() {
23484        let (_tmp, conn) = setup_test_db();
23485        let (_cols, rows) = execute_query(
23486            &conn,
23487            "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
23488        )
23489        .unwrap();
23490        assert_eq!(rows.len(), 3);
23491        assert_eq!(rows[0][0], serde_json::json!("Alice"));
23492        assert_eq!(rows[2][0], serde_json::json!("Bob"));
23493    }
23494
23495    #[test]
23496    fn sql_open_db_read_only() {
23497        let (tmp, _conn) = setup_test_db();
23498        drop(_conn);
23499        let ro_conn = open_db(tmp.path()).unwrap();
23500        let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
23501        assert!(result.is_err(), "read-only connection should reject writes");
23502    }
23503
23504    #[test]
23505    fn sql_empty_table_schema() {
23506        let tmp = tempfile::NamedTempFile::new().unwrap();
23507        let conn = Connection::open(tmp.path()).unwrap();
23508        conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
23509            .unwrap();
23510        let tables = schema_overview(&conn).unwrap();
23511        assert_eq!(tables[0].row_count, 0);
23512        assert_eq!(tables[0].columns.len(), 2);
23513    }
23514
23515    // --- graph command ---
23516
23517    fn setup_graph_index() -> tempfile::TempDir {
23518        let dir = tempfile::tempdir().unwrap();
23519        std::fs::write(
23520            dir.path().join("main.rs"),
23521            "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
23522        )
23523        .unwrap();
23524        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23525        db.apply_changes(dir.path()).unwrap();
23526        dir
23527    }
23528
23529    fn setup_traversal_project() -> tempfile::TempDir {
23530        let dir = setup_graph_index();
23531        let task_dir = dir.path().join("tasks/software");
23532        std::fs::create_dir_all(&task_dir).unwrap();
23533        std::fs::write(
23534            task_dir.join("tsift.md"),
23535            r#"---
23536agent_doc_session: tsift-v0.1
23537agent_doc_format: template
23538---
23539
23540## Exchange
23541
23542<!-- agent:exchange patch=append -->
23543❯ do [#kgnv]
23544Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
23545<!-- /agent:exchange -->
23546
23547<!-- agent:queue -->
23548dispatch #spec-test-build-install-commit-push
23549- do [#kgnv]
23550<!-- /agent:queue -->
23551
23552## Backlog
23553
23554<!-- agent:backlog -->
23555- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
23556<!-- /agent:backlog -->
23557"#,
23558        )
23559        .unwrap();
23560        dir
23561    }
23562
23563    fn resolve_ast_span_node<'a>(
23564        graph: &'a TraversalGraphBuild,
23565        label: &str,
23566        symbol_kind: &str,
23567    ) -> &'a TraversalNode {
23568        graph
23569            .nodes
23570            .values()
23571            .find(|node| {
23572                node.kind == "ast_span"
23573                    && node.label == label
23574                    && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
23575            })
23576            .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
23577    }
23578
23579    fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
23580        let dir = tempfile::tempdir().unwrap();
23581        std::fs::write(
23582            dir.path().join("rust.rs"),
23583            r#"mod fixture_nav_rust_mod {
23584    pub fn fixture_nav_rust_helper() {}
23585    pub fn fixture_nav_rust_entry() {
23586        fixture_nav_rust_helper();
23587    }
23588}
23589"#,
23590        )
23591        .unwrap();
23592        std::fs::write(
23593            dir.path().join("python.py"),
23594            r#"def fixture_nav_python_helper():
23595    return 1
23596
23597def fixture_nav_python_entry():
23598    return fixture_nav_python_helper()
23599"#,
23600        )
23601        .unwrap();
23602        std::fs::write(
23603            dir.path().join("typescript.ts"),
23604            r#"export function fixture_nav_typescript_entry(): number {
23605    return fixtureNavTsHelper();
23606}
23607
23608function fixtureNavTsHelper(): number {
23609    return 1;
23610}
23611"#,
23612        )
23613        .unwrap();
23614        std::fs::write(
23615            dir.path().join("javascript.js"),
23616            r#"function fixture_nav_javascript_entry() {
23617    return fixtureNavJsHelper();
23618}
23619
23620function fixtureNavJsHelper() {
23621    return 1;
23622}
23623"#,
23624        )
23625        .unwrap();
23626        std::fs::write(
23627            dir.path().join("kotlin.kt"),
23628            r#"fun fixture_nav_kotlin_entry(): Int {
23629    return fixtureNavKotlinHelper()
23630}
23631
23632fun fixtureNavKotlinHelper(): Int = 1
23633"#,
23634        )
23635        .unwrap();
23636        std::fs::write(
23637            dir.path().join("zig.zig"),
23638            r#"pub fn fixture_nav_zig_entry() i32 {
23639    return fixtureNavZigHelper();
23640}
23641
23642fn fixtureNavZigHelper() i32 {
23643    return 1;
23644}
23645"#,
23646        )
23647        .unwrap();
23648        std::fs::write(
23649            dir.path().join("bash.sh"),
23650            r#"#!/usr/bin/env bash
23651fixture_nav_bash_entry() {
23652    fixture_nav_bash_helper
23653}
23654
23655fixture_nav_bash_helper() {
23656    echo ok
23657}
23658
23659alias fixture_nav_bash_alias='echo alias'
23660"#,
23661        )
23662        .unwrap();
23663        std::fs::write(
23664            dir.path().join("README.md"),
23665            r#"# Fixture Guide
23666
23667## Fixture Section
23668
23669- Fixture step
23670  - Nested fixture step
23671
23672```python
23673def fixture_nav_markdown_embedded():
23674    return 1
23675```
23676"#,
23677        )
23678        .unwrap();
23679
23680        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23681        db.apply_changes(dir.path()).unwrap();
23682        dir
23683    }
23684
23685    fn assert_cli_expand_command_parses(command: &str) {
23686        let args = shell_split(command)
23687            .into_iter()
23688            .map(str::to_string)
23689            .collect::<Vec<_>>();
23690        assert!(
23691            try_parse_cli(args).is_ok(),
23692            "expand command should parse as a tsift CLI command: {command}"
23693        );
23694    }
23695
23696    fn setup_multiplicity_project() -> tempfile::TempDir {
23697        let dir = tempfile::tempdir().unwrap();
23698        std::fs::write(
23699            dir.path().join("Cargo.toml"),
23700            r#"[workspace]
23701members = ["crates/core-lib", "crates/cli-app"]
23702"#,
23703        )
23704        .unwrap();
23705        std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23706        std::fs::write(
23707            dir.path().join("crates/core-lib/Cargo.toml"),
23708            r#"[package]
23709name = "core-lib"
23710
23711[lib]
23712name = "core_lib"
23713
23714[features]
23715default = []
23716"#,
23717        )
23718        .unwrap();
23719        std::fs::write(
23720            dir.path().join("crates/core-lib/src/lib.rs"),
23721            "pub fn run() {}\n",
23722        )
23723        .unwrap();
23724        std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23725        std::fs::write(
23726            dir.path().join("crates/cli-app/Cargo.toml"),
23727            r#"[package]
23728name = "cli-app"
23729
23730[[bin]]
23731name = "cli-app"
23732
23733[dependencies]
23734core-lib = { path = "../core-lib" }
23735"#,
23736        )
23737        .unwrap();
23738        std::fs::write(
23739            dir.path().join("crates/cli-app/src/main.rs"),
23740            "use core_lib::run;\nfn main() { run(); }\n",
23741        )
23742        .unwrap();
23743        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23744        db.apply_changes(dir.path()).unwrap();
23745
23746        let task_dir = dir.path().join("tasks/software");
23747        std::fs::create_dir_all(&task_dir).unwrap();
23748        std::fs::write(
23749            task_dir.join("tsift.md"),
23750            r#"---
23751agent_doc_session: tsift-multiplicity
23752agent_doc_format: template
23753---
23754
23755## Backlog
23756
23757<!-- agent:backlog -->
23758- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23759<!-- /agent:backlog -->
23760"#,
23761        )
23762        .unwrap();
23763        init_git_repo(dir.path());
23764        dir
23765    }
23766
23767    fn setup_dependency_dag_project() -> tempfile::TempDir {
23768        let dir = tempfile::tempdir().unwrap();
23769        std::fs::write(
23770            dir.path().join("main.rs"),
23771            "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23772        )
23773        .unwrap();
23774        std::fs::write(
23775            dir.path().join("Cargo.toml"),
23776            "[package]\nname = \"dag-fixture\"\n",
23777        )
23778        .unwrap();
23779        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23780        db.apply_changes(dir.path()).unwrap();
23781
23782        let task_dir = dir.path().join("tasks/software");
23783        std::fs::create_dir_all(&task_dir).unwrap();
23784        std::fs::write(
23785            task_dir.join("tsift.md"),
23786            r#"---
23787agent_doc_session: tsift-dag
23788agent_doc_format: template
23789---
23790
23791## Exchange
23792
23793<!-- agent:exchange patch=append -->
23794Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23795<!-- /agent:exchange -->
23796
23797## Backlog
23798
23799<!-- agent:backlog -->
23800- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23801- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23802- [ ] [#beta] Refactor shared_helper tests in main.rs.
23803- [ ] [#gamma] Follow-up review for graph navigation.
23804<!-- /agent:backlog -->
23805"#,
23806        )
23807        .unwrap();
23808        dir
23809    }
23810
23811    fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23812        let dir = setup_graph_index();
23813        let task_dir = dir.path().join("tasks/software");
23814        std::fs::create_dir_all(&task_dir).unwrap();
23815        std::fs::write(
23816            task_dir.join("tsift.md"),
23817            r#"---
23818agent_doc_session: tsift-dag-cycle
23819agent_doc_format: template
23820---
23821
23822## Backlog
23823
23824<!-- agent:backlog -->
23825- [ ] [#left] Left side depends on #right.
23826- [ ] [#right] Right side depends on #left.
23827<!-- /agent:backlog -->
23828"#,
23829        )
23830        .unwrap();
23831        dir
23832    }
23833
23834    fn seed_traversal_semantic_summaries(dir: &Path) {
23835        let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23836        summary_db
23837            .insert(&summarize::Summary {
23838                id: 0,
23839                symbol_name: "helper".to_string(),
23840                file_path: "main.rs".to_string(),
23841                content_hash: "hash-main".to_string(),
23842                summary: "helper builds graph navigation handles for traversal.".to_string(),
23843                entities: Some(vec![
23844                    summarize::Entity {
23845                        name: "helper".to_string(),
23846                        kind: "function".to_string(),
23847                        description: "Builds graph navigation handles.".to_string(),
23848                    },
23849                    summarize::Entity {
23850                        name: "TraversalGraph".to_string(),
23851                        kind: "type".to_string(),
23852                        description: "Carries GraphStore-backed traversal rows.".to_string(),
23853                    },
23854                ]),
23855                relationships: Some(vec![summarize::Relationship {
23856                    from: "helper".to_string(),
23857                    to: "TraversalGraph".to_string(),
23858                    kind: "uses".to_string(),
23859                }]),
23860                concept_labels: Some(vec![
23861                    "graph navigation".to_string(),
23862                    "semantic extraction".to_string(),
23863                ]),
23864                extracted_at: "1700000000".to_string(),
23865                model: "test-model".to_string(),
23866                tokens_input: Some(10),
23867                tokens_output: Some(5),
23868            })
23869            .unwrap();
23870    }
23871
23872    fn seed_tsift_memory_graph_db(dir: &Path) {
23873        let db = dir.join(".tsift").join("memory.db");
23874        let store = MemoryStore::open_or_create(&db).unwrap();
23875        let project = dir.to_string_lossy().to_string();
23876        let observation = MemoryEvent::new(
23877            MemoryEventKind::ImportedObservation,
23878            "claude-mem:observations:1",
23879            [
23880                "Graph memory adapter",
23881                "read-only projection",
23882                "graph-db should retrieve tsift memory observations",
23883                "Project memory is queried from .tsift/memory.db",
23884                "graph memory, tsift memory, semantic query",
23885            ]
23886            .join("\n\n"),
23887        )
23888        .with_session_id("claude-session-a")
23889        .with_observed_at_unix(1_700_000_000)
23890        .with_import("claude-mem", "observations:1")
23891        .with_metadata("project", project.clone())
23892        .with_metadata("observation_type", "fact")
23893        .with_metadata("prompt_number", "7")
23894        .with_metadata("discovery_tokens", "42")
23895        .with_metadata("content_hash", "hash-observation-1");
23896        store.insert_event(&observation).unwrap();
23897
23898        let summary = MemoryEvent::new(
23899            MemoryEventKind::ImportedSessionSummary,
23900            "claude-mem:session_summaries:2",
23901            [
23902                "Query old memory from graph-db",
23903                "Read-only tsift memory SQLite projection",
23904                "Semantic graph rows can point at existing memory",
23905                "Projected source and session nodes",
23906                "Keep capture ownership inside tsift-memory",
23907                "summary note",
23908            ]
23909            .join("\n\n"),
23910        )
23911        .with_session_id("claude-session-a")
23912        .with_observed_at_unix(1_700_000_010)
23913        .with_import("claude-mem", "session_summaries:2")
23914        .with_metadata("project", project)
23915        .with_metadata("prompt_number", "8")
23916        .with_metadata("discovery_tokens", "36");
23917        store.insert_event(&summary).unwrap();
23918
23919        let prompt = MemoryEvent::new(
23920            MemoryEventKind::ImportedUserPrompt,
23921            "claude-mem:user_prompts:3",
23922            "How can graph-db query tsift memory semantic history?",
23923        )
23924        .with_session_id("claude-session-a")
23925        .with_observed_at_unix(1_700_000_020)
23926        .with_import("claude-mem", "user_prompts:3")
23927        .with_metadata("prompt_number", "9");
23928        store.insert_event(&prompt).unwrap();
23929    }
23930
23931    #[test]
23932    fn graph_callers_query() {
23933        let dir = setup_graph_index();
23934        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23935        let callers = db.callers_of("helper").unwrap();
23936        assert_eq!(callers.len(), 1);
23937        assert_eq!(callers[0].caller_name, "main");
23938    }
23939
23940    #[test]
23941    fn graph_callees_query() {
23942        let dir = setup_graph_index();
23943        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23944        let callees = db.callees_of("main").unwrap();
23945        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23946        assert!(names.contains(&"helper"));
23947        assert!(names.contains(&"new"));
23948    }
23949
23950    #[test]
23951    fn graph_no_callers_returns_empty() {
23952        let dir = setup_graph_index();
23953        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23954        let callers = db.callers_of("nonexistent").unwrap();
23955        assert!(callers.is_empty());
23956    }
23957
23958    #[test]
23959    fn graph_cmd_autoindexes_missing_index_by_default() {
23960        let dir = tempfile::tempdir().unwrap();
23961        std::fs::write(
23962            dir.path().join("main.rs"),
23963            "fn helper() {}\nfn main() { helper(); }\n",
23964        )
23965        .unwrap();
23966        let result = cmd_graph(
23967            "helper",
23968            dir.path(),
23969            true,
23970            false,
23971            None,
23972            20,
23973            false,
23974            true,
23975            false,
23976            false,
23977            false,
23978            false,
23979            false,
23980            TagpathSearchOpts::default(),
23981        );
23982
23983        assert!(result.is_ok());
23984        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23985        let summary = db.compute_changes(dir.path()).unwrap();
23986        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23987    }
23988
23989    #[test]
23990    fn traversal_graph_has_stable_typed_handles() {
23991        let dir = setup_traversal_project();
23992        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23993        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23994
23995        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23996        let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23997        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23998        let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23999
24000        assert!(file.handle.starts_with("gfil-"));
24001        assert!(symbol.handle.starts_with("gsym-"));
24002        assert!(backlog.handle.starts_with("gbak-"));
24003        assert!(session.handle.starts_with("gses-"));
24004
24005        assert_eq!(
24006            symbol.handle,
24007            resolve_traversal_node(&graph_again, "helper")
24008                .unwrap()
24009                .handle
24010        );
24011        assert_eq!(
24012            backlog.handle,
24013            resolve_traversal_node(&graph_again, "#kgnv")
24014                .unwrap()
24015                .handle
24016        );
24017    }
24018
24019    #[test]
24020    fn traversal_graph_links_backlog_items_to_code_tokens() {
24021        let dir = setup_traversal_project();
24022        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24023        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24024        let helper = resolve_traversal_node(&graph, "helper").unwrap();
24025
24026        assert!(graph.edges.iter().any(|edge| {
24027            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24028        }));
24029    }
24030
24031    #[test]
24032    fn session_hinted_traversal_skips_global_call_edges() {
24033        let dir = setup_traversal_project();
24034        let session = dir.path().join("tasks/software/tsift.md");
24035        let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
24036        let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
24037        let helper = resolve_traversal_node(&bounded, "helper").unwrap();
24038
24039        assert!(bounded.edges.iter().any(|edge| {
24040            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24041        }));
24042        assert!(
24043            !bounded.edges.iter().any(|edge| edge.relation == "calls"),
24044            "session-hinted graph-db projections should not materialize unrelated global call edges"
24045        );
24046
24047        let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24048        assert!(
24049            full.edges.iter().any(|edge| edge.relation == "calls"),
24050            "root/full projections still carry the complete indexed call graph"
24051        );
24052    }
24053
24054    #[test]
24055    fn agent_doc_task_path_infers_matching_workspace_scope() {
24056        let dir = tempfile::tempdir().unwrap();
24057        std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
24058        std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
24059        std::fs::write(
24060            dir.path().join(".gitmodules"),
24061            "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
24062        )
24063        .unwrap();
24064        let task = dir.path().join("tasks/software/tsift.md");
24065        std::fs::write(&task, "# tsift\n").unwrap();
24066
24067        let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
24068        let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
24069        let cfg = config::Config::load(dir.path()).unwrap();
24070
24071        assert_eq!(targets.len(), 1);
24072        assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
24073        assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
24074        assert!(
24075            targets[0]
24076                .db_path
24077                .ends_with(".tsift/indexes/tsift/index.db")
24078        );
24079        assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
24080    }
24081
24082    #[test]
24083    fn cargo_package_scope_selector_indexes_package_db() {
24084        let dir = setup_multiplicity_project();
24085        let targets =
24086            resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
24087
24088        assert_eq!(targets.len(), 1);
24089        assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
24090        assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
24091        assert!(
24092            targets[0]
24093                .db_path
24094                .ends_with(".tsift/indexes/cargo/core-lib/index.db")
24095        );
24096
24097        cmd_index(
24098            dir.path(),
24099            false,
24100            false,
24101            false,
24102            false,
24103            true,
24104            false,
24105            Some("core_lib"),
24106            false,
24107            true,
24108            false,
24109            false,
24110            false,
24111            false,
24112        )
24113        .unwrap();
24114        assert!(targets[0].db_path.exists());
24115    }
24116
24117    #[test]
24118    fn source_read_symbols_build_cargo_package_index_on_demand() {
24119        // A workspace member that has never been queried has no per-package cargo
24120        // index yet. source-read must build it on demand and return AST symbol
24121        // refs rather than silently degrading to window-only output with an
24122        // "index refs unavailable" warning while `tsift status` reports fresh
24123        // (#cargoidxcov).
24124        let dir = setup_multiplicity_project();
24125        let cargo_index = dir.path().join(".tsift/indexes/cargo/core-lib/index.db");
24126        assert!(
24127            !cargo_index.exists(),
24128            "core-lib cargo index should not exist before the first source-read"
24129        );
24130
24131        let file_abs = dir.path().join("crates/core-lib/src/lib.rs");
24132        let source = std::fs::read(&file_abs).unwrap();
24133        let mut warnings = Vec::new();
24134        let symbols = load_source_symbols(
24135            dir.path(),
24136            &file_abs,
24137            "crates/core-lib/src/lib.rs",
24138            &source,
24139            None,
24140            1,
24141            usize::MAX,
24142            10,
24143            4096,
24144            &mut warnings,
24145        );
24146
24147        assert!(
24148            warnings.is_empty(),
24149            "source-read must build the index on demand instead of warning: {warnings:?}"
24150        );
24151        let symbol_names = symbols
24152            .iter()
24153            .map(|symbol| symbol.name.as_str())
24154            .collect::<Vec<_>>();
24155        assert!(
24156            symbol_names.contains(&"run"),
24157            "source-read should resolve `run` from the on-demand-built cargo index: {symbol_names:?}"
24158        );
24159        assert!(
24160            cargo_index.exists(),
24161            "source-read should have built the core-lib cargo index on demand"
24162        );
24163    }
24164
24165    #[test]
24166    fn path_inference_prefers_nested_cargo_package_without_submodule() {
24167        let dir = setup_multiplicity_project();
24168        let source = dir.path().join("crates/cli-app/src/main.rs");
24169        let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
24170
24171        assert_eq!(targets.len(), 1);
24172        assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
24173        assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
24174    }
24175
24176    #[test]
24177    fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
24178        let dir = setup_multiplicity_project();
24179        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24180        let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
24181        let core = resolve_traversal_node(&graph, "core-lib").unwrap();
24182        let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
24183        let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
24184
24185        assert_eq!(workspace.kind, "cargo_workspace");
24186        assert_eq!(core.kind, "cargo_package");
24187        assert_eq!(
24188            core.properties.get("features"),
24189            Some(&"default".to_string())
24190        );
24191        assert!(graph.edges.iter().any(|edge| {
24192            edge.from == workspace.handle
24193                && edge.to == core.handle
24194                && edge.relation == "contains_package"
24195        }));
24196        assert!(graph.edges.iter().any(|edge| {
24197            edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
24198        }));
24199        assert!(graph.edges.iter().any(|edge| {
24200            edge.from == cli.handle
24201                && edge.to == core.handle
24202                && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
24203        }));
24204    }
24205
24206    #[test]
24207    fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
24208        let dir = setup_multiplicity_project();
24209        let session = dir.path().join("tasks/software/tsift.md");
24210        let report =
24211            build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
24212                .unwrap();
24213
24214        assert!(report.per_target_fail_closed.is_empty());
24215        let candidate = report
24216            .candidates
24217            .iter()
24218            .find(|candidate| candidate.target == "corepkg")
24219            .unwrap();
24220        assert!(
24221            candidate
24222                .owned_files
24223                .iter()
24224                .any(|file| file == "crates/core-lib/Cargo.toml"),
24225            "{:?}",
24226            candidate.owned_files
24227        );
24228    }
24229
24230    #[test]
24231    fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
24232        let dir = setup_traversal_project();
24233        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24234        let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
24235        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24236
24237        assert_eq!(job.kind, "job_packet");
24238        assert!(job.handle.starts_with("gjob-"));
24239        assert!(graph.edges.iter().any(|edge| {
24240            edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
24241        }));
24242
24243        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24244        let jobs = store.nodes_by_kind("job_packet").unwrap();
24245        assert!(
24246            jobs.iter()
24247                .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24248            "expected queued job packet in graph store, got {jobs:?}"
24249        );
24250    }
24251
24252    #[test]
24253    fn traversal_graph_includes_routes_and_handler_edges() {
24254        let dir = tempfile::tempdir().unwrap();
24255        std::fs::write(
24256            dir.path().join("api.py"),
24257            r#"@router.get("/items")
24258def list_items():
24259    return []
24260"#,
24261        )
24262        .unwrap();
24263        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24264        db.apply_changes(dir.path()).unwrap();
24265
24266        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24267        let route = resolve_traversal_node(&graph, "/items").unwrap();
24268        let handler = resolve_traversal_node(&graph, "list_items").unwrap();
24269
24270        assert_eq!(route.kind, "route");
24271        assert!(graph.edges.iter().any(|edge| {
24272            edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
24273        }));
24274    }
24275
24276    #[test]
24277    fn traversal_graph_projects_rust_ast_navigation_edges() {
24278        let dir = tempfile::tempdir().unwrap();
24279        std::fs::write(
24280            dir.path().join("main.rs"),
24281            r#"mod api {
24282    pub fn helper() {}
24283    pub fn handler() { helper(); }
24284}
24285
24286fn main() { api::handler(); }
24287"#,
24288        )
24289        .unwrap();
24290        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24291        db.apply_changes(dir.path()).unwrap();
24292
24293        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24294        let api = resolve_ast_span_node(&graph, "api", "mod");
24295        let helper = resolve_ast_span_node(&graph, "helper", "function");
24296        let handler = resolve_ast_span_node(&graph, "handler", "function");
24297
24298        assert_eq!(helper.kind, "ast_span");
24299        assert!(helper.handle.starts_with("span-"));
24300        assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
24301        assert!(graph.edges.iter().any(|edge| {
24302            edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
24303        }));
24304        assert!(graph.edges.iter().any(|edge| {
24305            edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
24306        }));
24307        assert!(graph.edges.iter().any(|edge| {
24308            edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
24309        }));
24310        assert!(graph.edges.iter().any(|edge| {
24311            edge.from == helper.handle
24312                && edge.to == handler.handle
24313                && edge.relation == "next_sibling"
24314        }));
24315        assert!(graph.edges.iter().any(|edge| {
24316            edge.from == handler.handle
24317                && edge.to == helper.handle
24318                && edge.relation == "previous_sibling"
24319        }));
24320        assert!(graph.edges.iter().any(|edge| {
24321            edge.from == helper.handle
24322                && edge.to == api.handle
24323                && edge.relation == "enclosing_module"
24324        }));
24325        assert!(graph.edges.iter().any(|edge| {
24326            edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
24327        }));
24328
24329        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24330        let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
24331        assert!(
24332            ast_nodes.iter().any(|node| node.id == helper.handle
24333                && node.properties.get("symbol_kind") == Some(&"function".to_string())),
24334            "expected helper AST span in graph store, got {ast_nodes:?}"
24335        );
24336        assert!(
24337            store
24338                .outgoing_edges(&helper.handle, Some("parent"))
24339                .unwrap()
24340                .iter()
24341                .any(|edge| edge.to_id == api.handle),
24342            "expected persisted AST parent edge"
24343        );
24344    }
24345
24346    #[test]
24347    fn traversal_graph_projects_markdown_section_block_edges() {
24348        let dir = tempfile::tempdir().unwrap();
24349        std::fs::write(
24350            dir.path().join("README.md"),
24351            "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
24352        )
24353        .unwrap();
24354        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24355        db.apply_changes(dir.path()).unwrap();
24356
24357        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24358        let guide = resolve_ast_span_node(&graph, "Guide", "heading");
24359        let code = resolve_ast_span_node(&graph, "rust", "code_block");
24360        let embedded = resolve_ast_span_node(&graph, "demo", "function");
24361        let list_item = graph
24362            .nodes
24363            .values()
24364            .find(|node| {
24365                node.kind == "ast_span"
24366                    && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
24367                    && node.properties.get("section_handle") == Some(&guide.handle)
24368            })
24369            .expect("missing Markdown list item AST span");
24370
24371        assert_eq!(
24372            code.properties.get("markdown_block_kind"),
24373            Some(&"fenced_code_block".to_string())
24374        );
24375        assert_eq!(
24376            guide.properties.get("heading_level"),
24377            Some(&"1".to_string())
24378        );
24379        assert_eq!(
24380            embedded.properties.get("embedded"),
24381            Some(&"true".to_string())
24382        );
24383        assert_eq!(
24384            embedded.properties.get("language"),
24385            Some(&"rust".to_string())
24386        );
24387        assert_eq!(
24388            embedded.properties.get("markdown_block_handle"),
24389            Some(&code.handle)
24390        );
24391        assert!(graph.edges.iter().any(|edge| {
24392            edge.from == guide.handle
24393                && edge.to == code.handle
24394                && edge.relation == "contains_markdown_block"
24395        }));
24396        assert!(graph.edges.iter().any(|edge| {
24397            edge.from == code.handle
24398                && edge.to == guide.handle
24399                && edge.relation == "enclosing_section"
24400        }));
24401        assert!(graph.edges.iter().any(|edge| {
24402            edge.from == guide.handle
24403                && edge.to == list_item.handle
24404                && edge.relation == "contains_markdown_block"
24405        }));
24406        assert!(graph.edges.iter().any(|edge| {
24407            edge.from == code.handle
24408                && edge.to == embedded.handle
24409                && edge.relation == "contains_embedded_symbol"
24410        }));
24411        assert!(graph.edges.iter().any(|edge| {
24412            edge.from == embedded.handle
24413                && edge.to == code.handle
24414                && edge.relation == "embedded_in_fence"
24415        }));
24416        assert!(graph.edges.iter().any(|edge| {
24417            edge.from == guide.handle
24418                && edge.to == embedded.handle
24419                && edge.relation == "contains_embedded_code"
24420        }));
24421
24422        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24423        assert!(
24424            store
24425                .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
24426                .unwrap()
24427                .iter()
24428                .any(|edge| edge.to_id == code.handle),
24429            "expected persisted Markdown section/block edge"
24430        );
24431        assert!(
24432            store
24433                .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
24434                .unwrap()
24435                .iter()
24436                .any(|edge| edge.to_id == embedded.handle),
24437            "expected persisted Markdown fence/embedded symbol edge"
24438        );
24439    }
24440
24441    #[test]
24442    fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
24443        let dir = setup_multilingual_ast_navigation_project();
24444        let db =
24445            index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
24446        let symbols = db.all_symbols().unwrap();
24447        let expected_symbols = [
24448            ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
24449            (
24450                "python",
24451                "fixture_nav_python_entry",
24452                "function",
24453                "python.py",
24454            ),
24455            (
24456                "typescript",
24457                "fixture_nav_typescript_entry",
24458                "function",
24459                "typescript.ts",
24460            ),
24461            (
24462                "javascript",
24463                "fixture_nav_javascript_entry",
24464                "function",
24465                "javascript.js",
24466            ),
24467            (
24468                "kotlin",
24469                "fixture_nav_kotlin_entry",
24470                "function",
24471                "kotlin.kt",
24472            ),
24473            ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
24474            ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
24475            ("markdown", "Fixture Section", "heading", "README.md"),
24476            ("markdown", "Fixture step", "list_item", "README.md"),
24477            ("markdown", "python", "code_block", "README.md"),
24478        ];
24479
24480        for (language, name, kind, file) in expected_symbols {
24481            let symbol = symbols
24482                .iter()
24483                .find(|symbol| {
24484                    symbol.language == language
24485                        && symbol.name == name
24486                        && symbol.kind == kind
24487                        && symbol.file.ends_with(file)
24488                })
24489                .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
24490            assert!(
24491                symbol.start_byte.is_some() && symbol.end_byte.is_some(),
24492                "{language} {name} should carry AST byte spans"
24493            );
24494        }
24495
24496        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24497        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24498        let expected_ast_nodes = [
24499            ("fixture_nav_rust_entry", "function", "rust"),
24500            ("fixture_nav_python_entry", "function", "python"),
24501            ("fixture_nav_typescript_entry", "function", "typescript"),
24502            ("fixture_nav_javascript_entry", "function", "javascript"),
24503            ("fixture_nav_kotlin_entry", "function", "kotlin"),
24504            ("fixture_nav_zig_entry", "function", "zig"),
24505            ("fixture_nav_bash_entry", "function", "bash"),
24506            ("Fixture Section", "heading", "markdown"),
24507            ("Fixture step", "list_item", "markdown"),
24508            ("python", "code_block", "markdown"),
24509            ("fixture_nav_markdown_embedded", "function", "python"),
24510        ];
24511
24512        for (name, kind, language) in expected_ast_nodes {
24513            let node = resolve_ast_span_node(&graph, name, kind);
24514            let repeated = resolve_ast_span_node(&graph_again, name, kind);
24515            assert!(
24516                node.handle.starts_with("span-"),
24517                "{name} handle: {}",
24518                node.handle
24519            );
24520            assert_eq!(
24521                node.handle, repeated.handle,
24522                "{language} {name} handle drifted"
24523            );
24524            assert_eq!(
24525                node.properties.get("language"),
24526                Some(&language.to_string()),
24527                "{name} should keep its language label"
24528            );
24529        }
24530
24531        let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
24532        let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
24533        let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
24534        assert!(graph.edges.iter().any(|edge| {
24535            edge.from == markdown_section.handle
24536                && edge.to == markdown_code.handle
24537                && edge.relation == "contains_markdown_block"
24538        }));
24539        assert!(graph.edges.iter().any(|edge| {
24540            edge.from == markdown_code.handle
24541                && edge.to == embedded.handle
24542                && edge.relation == "contains_embedded_symbol"
24543        }));
24544        assert!(
24545            graph.nodes.len() <= 80,
24546            "multilingual AST fixture should stay bounded, got {} nodes",
24547            graph.nodes.len()
24548        );
24549        assert!(
24550            graph.edges.len() <= 180,
24551            "multilingual AST fixture should stay bounded, got {} edges",
24552            graph.edges.len()
24553        );
24554
24555        let response = empty_search_response(dir.path(), "lexical");
24556        let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
24557        let report = build_relative_search_budget_report(
24558            "fixture_nav_python_entry",
24559            "lexical",
24560            dir.path(),
24561            &response,
24562            &symbol_hits,
24563            ResponseBudget::new(Some(8), Some(120)),
24564            &SearchFacetFilters::default(),
24565        );
24566        let report_again = build_relative_search_budget_report(
24567            "fixture_nav_python_entry",
24568            "lexical",
24569            dir.path(),
24570            &response,
24571            &symbol_hits,
24572            ResponseBudget::new(Some(8), Some(120)),
24573            &SearchFacetFilters::default(),
24574        );
24575
24576        let top = report
24577            .ranked
24578            .first()
24579            .expect("ranked preview should not be empty");
24580        assert_eq!(top.source, "symbol_span");
24581        assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
24582        assert!(top.handle.starts_with("srnk-"));
24583        assert_eq!(top.handle, report_again.ranked[0].handle);
24584        assert!(
24585            top.reasons.iter().any(|reason| reason == "ast_span"),
24586            "expected AST span ranking reason, got {:?}",
24587            top.reasons
24588        );
24589        assert!(report.ranked.len() <= 8);
24590        assert!(report.symbols.len() <= 8);
24591
24592        let symbol = report
24593            .symbols
24594            .iter()
24595            .find(|symbol| symbol.name == "fixture_nav_python_entry")
24596            .expect("missing search preview symbol");
24597        assert_cli_expand_command_parses(&symbol.expand);
24598        let ast = symbol
24599            .ast
24600            .as_ref()
24601            .expect("search symbol should expose AST");
24602        assert_cli_expand_command_parses(&ast.expand.source_window);
24603        assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
24604        assert_cli_expand_command_parses(&ast.expand.symbol_read);
24605
24606        let markdown_hits = db.symbol_search("python", 20).unwrap();
24607        let markdown_report = build_relative_search_budget_report(
24608            "python",
24609            "lexical",
24610            dir.path(),
24611            &response,
24612            &markdown_hits,
24613            ResponseBudget::new(Some(8), Some(120)),
24614            &SearchFacetFilters::default(),
24615        );
24616        let markdown_symbol = markdown_report
24617            .symbols
24618            .iter()
24619            .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
24620            .expect("missing Markdown code-block symbol");
24621        let markdown_ast = markdown_symbol
24622            .ast
24623            .as_ref()
24624            .expect("Markdown code block should expose AST");
24625        assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
24626        assert_eq!(
24627            markdown_ast
24628                .span
24629                .markdown
24630                .as_ref()
24631                .unwrap()
24632                .embedded_symbols[0]
24633                .name,
24634            "fixture_nav_markdown_embedded"
24635        );
24636    }
24637
24638    #[test]
24639    fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
24640        let edges = vec![
24641            TraversalEdge {
24642                from: "origin".to_string(),
24643                to: "aaa_low".to_string(),
24644                relation: "unknown".to_string(),
24645                label: None,
24646                weight: 1,
24647            },
24648            TraversalEdge {
24649                from: "origin".to_string(),
24650                to: "zzz_high".to_string(),
24651                relation: "mentions".to_string(),
24652                label: None,
24653                weight: 1,
24654            },
24655        ];
24656
24657        let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
24658
24659        assert!(handles.contains("origin"));
24660        assert!(handles.contains("zzz_high"), "{handles:?}");
24661        assert!(!handles.contains("aaa_low"), "{handles:?}");
24662    }
24663
24664    #[test]
24665    fn traversal_materializes_provider_neutral_sqlite_graph() {
24666        let dir = setup_traversal_project();
24667        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24668        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24669
24670        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24671        let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
24672        assert!(
24673            backlog_nodes.iter().any(|node| node.id == backlog.handle
24674                && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24675            "expected materialized backlog node, got {backlog_nodes:?}"
24676        );
24677        assert!(
24678            store
24679                .all_nodes()
24680                .unwrap()
24681                .iter()
24682                .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
24683                    && node.properties.get("projection_version")
24684                        == Some(&GRAPH_PROJECTION_VERSION.to_string())),
24685            "expected projection metadata node"
24686        );
24687        let source_handles = store.nodes_by_kind("source_handle").unwrap();
24688        assert!(
24689            source_handles
24690                .iter()
24691                .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
24692            "expected bounded source_handle rows, got {source_handles:?}"
24693        );
24694        let worker_context = store.nodes_by_kind("worker_context").unwrap();
24695        assert!(
24696            worker_context
24697                .iter()
24698                .any(|node| node.properties.get("target")
24699                    == Some(&"tasks/software/tsift.md".to_string())),
24700            "expected bounded worker_context rows, got {worker_context:?}"
24701        );
24702        let worker_results = store.nodes_by_kind("worker_result").unwrap();
24703        assert!(
24704            worker_results.iter().any(|node| {
24705                node.properties.get("ref_id") == Some(&"kgnv".to_string())
24706                    && node.properties.get("status") == Some(&"completed".to_string())
24707                    && node.properties.get("touched_files") == Some(&"main.rs".to_string())
24708                    && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
24709            }),
24710            "expected worker_result rows, got {worker_results:?}"
24711        );
24712    }
24713
24714    #[test]
24715    fn traversal_projection_materializes_cached_semantic_rows() {
24716        let dir = setup_traversal_project();
24717        seed_traversal_semantic_summaries(dir.path());
24718        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24719        let helper = resolve_traversal_node(&graph, "helper").unwrap();
24720        let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
24721        let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
24722
24723        assert_eq!(concept.kind, "semantic_concept");
24724        assert_eq!(entity.kind, "semantic_entity");
24725        assert!(concept.handle.starts_with("gcon-"));
24726        assert!(entity.handle.starts_with("gent-"));
24727
24728        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24729        assert!(
24730            store
24731                .nodes_by_kind("semantic_concept")
24732                .unwrap()
24733                .iter()
24734                .any(|node| node.label == "semantic extraction"
24735                    && node.properties.contains_key("embedding")),
24736            "expected persisted concept embeddings"
24737        );
24738        assert!(
24739            store
24740                .outgoing_edges(&helper.handle, Some("mentions_concept"))
24741                .unwrap()
24742                .iter()
24743                .any(|edge| edge.to_id == concept.handle),
24744            "expected helper symbol to link to cached summary concept"
24745        );
24746        assert!(
24747            store
24748                .outgoing_edges(
24749                    &semantic_entity_handle("helper", "function"),
24750                    Some("semantic_relation")
24751                )
24752                .unwrap()
24753                .iter()
24754                .any(|edge| edge.to_id == entity.handle
24755                    && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24756            "expected LLM relationship rows projected into GraphStore"
24757        );
24758    }
24759
24760    #[test]
24761    fn traversal_projection_materializes_tsift_memory_rows() {
24762        let dir = setup_traversal_project();
24763        seed_tsift_memory_graph_db(dir.path());
24764        let memory_db = dir.path().join(".tsift").join("memory.db");
24765        let store = MemoryStore::open_or_create(&memory_db).unwrap();
24766        for summary in ["first closeout", "second closeout"] {
24767            let event = MemoryEvent::new(
24768                MemoryEventKind::ResponseSummary,
24769                "tasks/software/tsift.md",
24770                summary,
24771            )
24772            .with_session_id("tasks/software/tsift.md")
24773            .with_observed_at_unix(1_700_000_100);
24774            store.insert_event(&event).unwrap();
24775        }
24776        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24777        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24778
24779        let native_sources = store
24780            .nodes_by_kind("source_handle")
24781            .unwrap()
24782            .into_iter()
24783            .filter(|node| {
24784                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24785                    && node.properties.get("source_ref")
24786                        == Some(&"tasks/software/tsift.md".to_string())
24787            })
24788            .collect::<Vec<_>>();
24789        assert_eq!(
24790            native_sources.len(),
24791            2,
24792            "same-source native memory events must get distinct source handles"
24793        );
24794
24795        let source = store
24796            .nodes_by_kind("source_handle")
24797            .unwrap()
24798            .into_iter()
24799            .find(|node| {
24800                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24801            })
24802            .expect("expected tsift-memory source handle");
24803        let session = store
24804            .nodes_by_kind("memory_session")
24805            .unwrap()
24806            .into_iter()
24807            .find(|node| {
24808                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24809                    && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24810            })
24811            .expect("expected tsift-memory session node");
24812        let event = store
24813            .nodes_by_kind("memory_event")
24814            .unwrap()
24815            .into_iter()
24816            .find(|node| {
24817                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24818                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24819                    && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24820            })
24821            .expect("expected tsift-memory event node");
24822        let concept = store
24823            .nodes_by_kind("semantic_concept")
24824            .unwrap()
24825            .into_iter()
24826            .find(|node| {
24827                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24828                    && node.label.contains("Graph memory adapter")
24829                    && node.properties.contains_key("embedding")
24830            })
24831            .expect("expected tsift-memory semantic concept");
24832
24833        assert!(
24834            store
24835                .outgoing_edges(&session.id, Some("records_memory_source"))
24836                .unwrap()
24837                .iter()
24838                .any(|edge| edge.to_id == source.id),
24839            "expected session to link to source handle"
24840        );
24841        assert!(
24842            store
24843                .outgoing_edges(&session.id, Some("records_memory_event"))
24844                .unwrap()
24845                .iter()
24846                .any(|edge| edge.to_id == event.id),
24847            "expected session to link to memory event"
24848        );
24849        assert!(
24850            store
24851                .outgoing_edges(&event.id, Some("projects_source"))
24852                .unwrap()
24853                .iter()
24854                .any(|edge| edge.to_id == source.id),
24855            "expected memory event to project source handle"
24856        );
24857        assert!(
24858            store
24859                .outgoing_edges(&source.id, Some("mentions_concept"))
24860                .unwrap()
24861                .iter()
24862                .any(|edge| edge.to_id == concept.id),
24863            "expected source handle to seed semantic concept"
24864        );
24865
24866        let related = semantic_related_report_from_store(
24867            dir.path(),
24868            None,
24869            "tsift memory graph adapter",
24870            5,
24871            SemanticRelatedKind::Concept,
24872            &store,
24873        )
24874        .unwrap();
24875        assert!(
24876            related
24877                .items
24878                .iter()
24879                .any(|item| item.handle == concept.id && item.score > 0.0),
24880            "expected semantic query to retrieve tsift-memory concept, got {:?}",
24881            related.items
24882        );
24883
24884        let graph_related = graph_db_report_from_store(
24885            dir.path(),
24886            None,
24887            "sqlite",
24888            GraphDbQuery::Related {
24889                query: "tsift memory graph adapter".to_string(),
24890                kind: SemanticRelatedKind::Concept,
24891                depth: 1,
24892                seed_limit: 5,
24893                limit: 20,
24894            },
24895            &store,
24896            sqlite_graph_freshness(&store, "root").unwrap(),
24897            Vec::new(),
24898        )
24899        .unwrap();
24900        assert_eq!(
24901            graph_related
24902                .readiness
24903                .as_ref()
24904                .map(|readiness| readiness.status.as_str()),
24905            Some("ready"),
24906            "tsift-memory semantic rows should satisfy graph-db related readiness"
24907        );
24908        assert!(
24909            graph_related.nodes.iter().any(|node| {
24910                node.kind == "semantic_concept"
24911                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24912            }),
24913            "expected related graph output to include tsift-memory semantic rows"
24914        );
24915    }
24916
24917    #[test]
24918    fn semantic_related_query_uses_persisted_graph_embeddings() {
24919        let dir = setup_traversal_project();
24920        seed_traversal_semantic_summaries(dir.path());
24921        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24922        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24923        let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
24924            .unwrap()
24925            .query_row(
24926                "SELECT COUNT(*) FROM graph_node_semantic_vectors",
24927                [],
24928                |row| row_usize(row, 0),
24929            )
24930            .unwrap();
24931        assert!(semantic_vector_rows > 0);
24932
24933        let report = semantic_related_report_from_store(
24934            dir.path(),
24935            None,
24936            "graph navigation",
24937            5,
24938            SemanticRelatedKind::Concept,
24939            &store,
24940        )
24941        .unwrap();
24942
24943        assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
24944        assert!(
24945            report
24946                .items
24947                .iter()
24948                .any(|item| item.label == "graph navigation"
24949                    && item.kind == "semantic_concept"
24950                    && item.score > 0.9),
24951            "expected nearest concept match from graph embeddings, got {:?}",
24952            report.items
24953        );
24954    }
24955
24956    #[test]
24957    fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
24958        let dir = setup_traversal_project();
24959        seed_traversal_semantic_summaries(dir.path());
24960        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24961        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24962
24963        let report = graph_db_report_from_store(
24964            dir.path(),
24965            None,
24966            "sqlite",
24967            GraphDbQuery::Related {
24968                query: "graph navigation".to_string(),
24969                kind: SemanticRelatedKind::All,
24970                depth: 1,
24971                seed_limit: 2,
24972                limit: 20,
24973            },
24974            &store,
24975            sqlite_graph_freshness(&store, "root").unwrap(),
24976            Vec::new(),
24977        )
24978        .unwrap();
24979
24980        let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24981        assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24982        assert_eq!(knowledge.seed_kind, "all");
24983        assert_eq!(knowledge.depth, 1);
24984        assert_eq!(
24985            report
24986                .readiness
24987                .as_ref()
24988                .map(|readiness| readiness.status.as_str()),
24989            Some("ready")
24990        );
24991        assert!(
24992            knowledge
24993                .diagnostics
24994                .iter()
24995                .any(|diagnostic| diagnostic.contains("incident"))
24996        );
24997        assert!(
24998            report
24999                .semantic_related
25000                .iter()
25001                .any(|item| item.label == "graph navigation"
25002                    && item.kind == "semantic_concept"
25003                    && item.score > 0.9),
25004            "expected natural-language query to seed the graph navigation concept, got {:?}",
25005            report.semantic_related
25006        );
25007        assert!(
25008            report
25009                .nodes
25010                .iter()
25011                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
25012        );
25013        assert!(
25014            report
25015                .nodes
25016                .iter()
25017                .any(|node| node.kind == "symbol" && node.label == "helper"),
25018            "incident expansion from semantic seed should recover source symbols, got {:?}",
25019            report
25020                .nodes
25021                .iter()
25022                .map(|node| (&node.kind, &node.label))
25023                .collect::<Vec<_>>()
25024        );
25025        assert!(
25026            report
25027                .edges
25028                .iter()
25029                .any(|edge| edge.kind == "mentions_concept")
25030        );
25031        assert!(
25032            report.output_budget.as_ref().is_some_and(|budget| budget
25033                .diagnostics
25034                .iter()
25035                .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
25036            "expected related output budget diagnostics, got {:?}",
25037            report.output_budget
25038        );
25039    }
25040
25041    #[test]
25042    fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
25043        let dir = setup_graph_index();
25044        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25045        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25046
25047        let report = graph_db_report_from_store(
25048            dir.path(),
25049            None,
25050            "sqlite",
25051            GraphDbQuery::Related {
25052                query: "graph navigation".to_string(),
25053                kind: SemanticRelatedKind::All,
25054                depth: 1,
25055                seed_limit: 2,
25056                limit: 20,
25057            },
25058            &store,
25059            sqlite_graph_freshness(&store, "root").unwrap(),
25060            Vec::new(),
25061        )
25062        .unwrap();
25063
25064        let readiness = report.readiness.as_ref().unwrap();
25065        assert_eq!(readiness.status, "blocked");
25066        assert_eq!(readiness.reason, "summary_cache_empty");
25067        assert!(readiness.fail_closed);
25068        assert_eq!(
25069            readiness.next_commands,
25070            vec![
25071                "tsift summarize --extract .".to_string(),
25072                graph_db_refresh_command(dir.path(), None)
25073            ]
25074        );
25075        assert!(
25076            report
25077                .knowledge_retrieval
25078                .as_ref()
25079                .unwrap()
25080                .diagnostics
25081                .iter()
25082                .any(|diagnostic| diagnostic.contains("summary cache empty")
25083                    && diagnostic.contains("graph-db materialized code/session rows")),
25084            "expected related diagnostics to carry readiness gate, got {:?}",
25085            report.knowledge_retrieval.as_ref().unwrap().diagnostics
25086        );
25087    }
25088
25089    #[test]
25090    fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
25091        let mut nodes = vec![
25092            SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
25093            SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
25094        ];
25095        let mut edges = vec![SubstrateGraphEdge::new(
25096            "zzz_high",
25097            "seed",
25098            "mentions_concept",
25099        )];
25100        for idx in 0..24 {
25101            let id = format!("aaa_low_{idx:02}");
25102            nodes.push(SubstrateGraphNode::new(
25103                id.clone(),
25104                "note",
25105                format!("low {idx}"),
25106            ));
25107            edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
25108        }
25109        let mut store = SqliteGraphStore::in_memory().unwrap();
25110        store
25111            .replace_projection(&GraphProjection { nodes, edges })
25112            .unwrap();
25113
25114        let subgraph =
25115            graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
25116
25117        assert_eq!(subgraph.nodes.len(), 3);
25118        assert_eq!(subgraph.nodes[0].id, "seed");
25119        assert_eq!(
25120            subgraph.nodes[1].id, "zzz_high",
25121            "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
25122            subgraph.nodes
25123        );
25124        assert!(subgraph.truncated);
25125        assert!(
25126            subgraph
25127                .diagnostics
25128                .iter()
25129                .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
25130            "{:?}",
25131            subgraph.diagnostics
25132        );
25133        assert!(
25134            subgraph
25135                .diagnostics
25136                .iter()
25137                .any(|diagnostic| diagnostic.contains("skipped")),
25138            "{:?}",
25139            subgraph.diagnostics
25140        );
25141    }
25142
25143    #[test]
25144    fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
25145        let dir = setup_traversal_project();
25146        seed_traversal_semantic_summaries(dir.path());
25147        init_git_repo(dir.path());
25148        let session = dir.path().join("tasks/software/tsift.md");
25149        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25150        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25151        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25152        let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25153            root: dir.path(),
25154            scope: None,
25155            backend: "sqlite",
25156            target: "kgnv",
25157            preferred_path: None,
25158            depth: 4,
25159            limit: 8,
25160            cursor: None,
25161            store: &store,
25162            freshness,
25163            warnings: Vec::new(),
25164        })
25165        .unwrap();
25166        assert!(
25167            evidence
25168                .semantic_related
25169                .iter()
25170                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
25171            "expected semantic evidence rows, got {:?}",
25172            evidence
25173                .semantic_related
25174                .iter()
25175                .map(|node| (&node.kind, &node.label))
25176                .collect::<Vec<_>>()
25177        );
25178        assert!(
25179            evidence
25180                .output_budget
25181                .as_ref()
25182                .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
25183                    diagnostic.contains("semantic_match")
25184                        && diagnostic.contains("source_handle_coverage")
25185                })),
25186            "expected evidence output budget diagnostics, got {:?}",
25187            evidence.output_budget
25188        );
25189
25190        let cached_diff = diff_digest::compute(
25191            dir.path(),
25192            diff_digest::DiffDigestOptions {
25193                cached: true,
25194                revision: None,
25195                max_parsed_files: None,
25196            },
25197        )
25198        .unwrap();
25199        let impact_report = impact::compute(
25200            dir.path(),
25201            impact::ImpactOptions {
25202                cached: true,
25203                revision: None,
25204                scope: None,
25205                limit: 10,
25206            },
25207        )
25208        .unwrap();
25209        let graph_nodes = store.all_nodes().unwrap();
25210        let graph_index = conflict_matrix_graph_index(&graph_nodes);
25211        let semantic_candidate = conflict_matrix_candidate_from_evidence(
25212            dir.path(),
25213            &evidence,
25214            &graph_index,
25215            &cached_diff,
25216            &impact_report,
25217        );
25218        assert!(semantic_candidate.semantic_dispatch_score > 0);
25219        assert!(
25220            semantic_candidate
25221                .semantic_dispatch_reasons
25222                .iter()
25223                .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
25224            "expected semantic ranking explanations, got {:?}",
25225            semantic_candidate.semantic_dispatch_reasons
25226        );
25227        assert!(
25228            semantic_candidate
25229                .semantic_related
25230                .iter()
25231                .any(|item| item.label == "graph navigation")
25232        );
25233
25234        let mut plain_candidate = semantic_candidate.clone();
25235        plain_candidate.target = "plain".to_string();
25236        plain_candidate.semantic_related.clear();
25237        plain_candidate.semantic_dispatch_score = 0;
25238        plain_candidate.semantic_dispatch_reasons.clear();
25239        let mut ranked = [plain_candidate, semantic_candidate];
25240        ranked.sort_by(|left, right| {
25241            left.risk
25242                .cmp(&right.risk)
25243                .then_with(|| left.risk_score.cmp(&right.risk_score))
25244                .then_with(|| {
25245                    right
25246                        .semantic_dispatch_score
25247                        .cmp(&left.semantic_dispatch_score)
25248                })
25249                .then_with(|| left.target.cmp(&right.target))
25250        });
25251        assert_eq!(ranked[0].target, "kgnv");
25252    }
25253
25254    #[test]
25255    fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
25256        let dir = setup_dependency_dag_project();
25257        let session = dir.path().join("tasks/software/tsift.md");
25258        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25259
25260        assert_eq!(report.contract_version, "dependency-dag-v1");
25261        assert_eq!(
25262            report.targets,
25263            vec![
25264                "prep".to_string(),
25265                "alpha".to_string(),
25266                "beta".to_string(),
25267                "gamma".to_string()
25268            ]
25269        );
25270        assert!(report.edges.iter().any(|edge| {
25271            edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
25272        }));
25273        assert!(report.edges.iter().any(|edge| {
25274            edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
25275        }));
25276        assert!(report.edges.iter().any(|edge| {
25277            edge.from == "alpha"
25278                && edge.to == "beta"
25279                && edge.kind == "shared_resource"
25280                && edge.shared_files.contains(&"main.rs".to_string())
25281                && edge.shared_symbols.contains(&"shared_helper".to_string())
25282        }));
25283        assert!(
25284            !report.cycle_diagnostics.has_cycles,
25285            "{:?}",
25286            report.cycle_diagnostics
25287        );
25288        assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
25289        assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
25290        assert!(
25291            report.replay_commands[0].contains("dependency-dag"),
25292            "{:?}",
25293            report.replay_commands
25294        );
25295
25296        cmd_dependency_dag(
25297            &session,
25298            None,
25299            &["alpha".to_string(), "beta".to_string()],
25300            4,
25301            12,
25302            OutputFormat {
25303                json_output: true,
25304                compact: false,
25305                pretty: false,
25306                terse: false,
25307                ultra_terse: false,
25308                schema: false,
25309                envelope: false,
25310            },
25311        )
25312        .unwrap();
25313    }
25314
25315    #[test]
25316    fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
25317        let dir = setup_dependency_dag_cycle_project();
25318        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25319
25320        assert!(report.cycle_diagnostics.has_cycles);
25321        assert_eq!(
25322            report.cycle_diagnostics.blocked_nodes,
25323            vec!["left".to_string(), "right".to_string()]
25324        );
25325        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25326            edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
25327        }));
25328        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25329            edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
25330        }));
25331    }
25332
25333    #[test]
25334    fn traversal_projection_queries_match_sqlite_and_convex_stores() {
25335        let dir = setup_traversal_project();
25336        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
25337        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
25338
25339        let mut sqlite = SqliteGraphStore::in_memory().unwrap();
25340        sqlite.replace_projection(&projection).unwrap();
25341        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25342        projection.upsert_into(&convex).unwrap();
25343
25344        let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
25345        let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
25346        assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
25347        assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
25348
25349        let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
25350        let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
25351        assert!(convex_graph.edges.iter().any(|edge| {
25352            edge.from == sqlite_backlog.handle
25353                && edge.to == convex_helper.handle
25354                && edge.relation == "mentions"
25355        }));
25356    }
25357
25358    #[test]
25359    fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
25360        let dir = setup_traversal_project();
25361        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25362        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25363        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25364        assert_eq!(freshness.status, "current");
25365
25366        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25367        let report = graph_db_report_from_store(
25368            dir.path(),
25369            None,
25370            "sqlite",
25371            GraphDbQuery::Neighborhood {
25372                id: backlog.handle.clone(),
25373                depth: 1,
25374                edge_kind: Some("mentions".to_string()),
25375                cursor: None,
25376                limit: None,
25377                property_filters: Vec::new(),
25378            },
25379            &store,
25380            freshness,
25381            Vec::new(),
25382        )
25383        .unwrap();
25384        assert!(
25385            report
25386                .edges
25387                .iter()
25388                .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
25389            "expected backlog mention edge, got {:?}",
25390            report.edges
25391        );
25392        assert!(
25393            report.ranked_neighbors.iter().any(|neighbor| {
25394                neighbor.depth == Some(1)
25395                    && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
25396                    && neighbor.node_id != backlog.handle
25397                    && neighbor.handle_coverage_pct >= 95.0
25398                    && neighbor.duplicate_name_precision >= 0.99
25399            }),
25400            "expected ranked neighborhood neighbors with quality scores, got {:?}",
25401            report.ranked_neighbors
25402        );
25403        assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
25404        let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
25405        assert!(!ranking_gate.ranked_output_default);
25406        assert_eq!(ranking_gate.default_order, "stable_node_id");
25407        assert!(
25408            ranking_gate
25409                .diagnostics
25410                .iter()
25411                .any(|diagnostic| diagnostic.contains("score-capped")),
25412            "{ranking_gate:?}"
25413        );
25414        assert!(
25415            ranking_gate
25416                .required_metrics
25417                .iter()
25418                .any(|metric| metric == "handle_coverage_pct")
25419        );
25420        assert!(
25421            ranking_gate
25422                .required_metrics
25423                .iter()
25424                .any(|metric| metric == "duplicate_name_precision")
25425        );
25426        assert!(
25427            report
25428                .page
25429                .as_ref()
25430                .unwrap()
25431                .diagnostics
25432                .iter()
25433                .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
25434            "expected SQLite neighborhood query plan diagnostics, got {:?}",
25435            report.page.as_ref().unwrap().diagnostics
25436        );
25437        let edges_report = graph_db_report_from_store(
25438            dir.path(),
25439            None,
25440            "sqlite",
25441            GraphDbQuery::Edges {
25442                edge_kind: Some("mentions".to_string()),
25443                cursor: None,
25444                limit: Some(2),
25445                property_filters: Vec::new(),
25446            },
25447            &store,
25448            sqlite_graph_freshness(&store, "root").unwrap(),
25449            Vec::new(),
25450        )
25451        .unwrap();
25452        let edge_id = edges_report
25453            .edges
25454            .first()
25455            .map(|edge| edge.id.clone())
25456            .expect("expected at least one paged mentions edge");
25457        assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
25458        assert_eq!(
25459            edges_report.page.as_ref().unwrap().returned_edges,
25460            edges_report.edges.len()
25461        );
25462
25463        let edge_report = graph_db_report_from_store(
25464            dir.path(),
25465            None,
25466            "sqlite",
25467            GraphDbQuery::Edge {
25468                id: edge_id.clone(),
25469            },
25470            &store,
25471            sqlite_graph_freshness(&store, "root").unwrap(),
25472            Vec::new(),
25473        )
25474        .unwrap();
25475        assert_eq!(
25476            edge_report
25477                .edge
25478                .as_ref()
25479                .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
25480            Some(edge_id.clone())
25481        );
25482
25483        let incident_report = graph_db_report_from_store(
25484            dir.path(),
25485            None,
25486            "sqlite",
25487            GraphDbQuery::Incident {
25488                id: backlog.handle.clone(),
25489                edge_kind: Some("mentions".to_string()),
25490                cursor: None,
25491                limit: Some(1),
25492                property_filters: Vec::new(),
25493            },
25494            &store,
25495            sqlite_graph_freshness(&store, "root").unwrap(),
25496            Vec::new(),
25497        )
25498        .unwrap();
25499        assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
25500        assert!(
25501            incident_report
25502                .edges
25503                .iter()
25504                .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
25505            "{:?}",
25506            incident_report.edges
25507        );
25508
25509        let schema_report = graph_db_report_from_store(
25510            dir.path(),
25511            None,
25512            "sqlite",
25513            GraphDbQuery::Schema,
25514            &store,
25515            sqlite_graph_freshness(&store, "root").unwrap(),
25516            Vec::new(),
25517        )
25518        .unwrap();
25519        assert!(
25520            schema_report
25521                .schema
25522                .unwrap()
25523                .operations
25524                .iter()
25525                .any(|operation| operation.command.starts_with("neighborhood"))
25526        );
25527    }
25528
25529    #[test]
25530    fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
25531        let mut nodes = vec![SubstrateGraphNode::new(
25532            "origin",
25533            "backlog",
25534            "#budgeted-neighborhood",
25535        )];
25536        let mut edges = Vec::new();
25537        for idx in 0..32 {
25538            let id = format!("src-{idx:02}");
25539            nodes.push(
25540                SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
25541                    .with_property("source_ref", format!("fixture:{idx}"))
25542                    .with_property("detail", "x".repeat(600)),
25543            );
25544            edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
25545        }
25546        let store = SqliteGraphStore::in_memory().unwrap();
25547        GraphProjection { nodes, edges }
25548            .upsert_into(&store)
25549            .unwrap();
25550
25551        let report = graph_db_report_from_store(
25552            Path::new("."),
25553            None,
25554            "fixture",
25555            GraphDbQuery::Neighborhood {
25556                id: "origin".to_string(),
25557                depth: 1,
25558                edge_kind: None,
25559                cursor: None,
25560                limit: None,
25561                property_filters: Vec::new(),
25562            },
25563            &store,
25564            current_graph_db_freshness(),
25565            Vec::new(),
25566        )
25567        .unwrap();
25568        let budget = report.output_budget.as_ref().unwrap();
25569        assert!(budget.selected_nodes < budget.candidate_nodes);
25570        assert!(
25571            budget.dropped_by_budget.iter().any(|drop| {
25572                drop.item == "node"
25573                    && drop.kind == "source_handle"
25574                    && drop.reason == "per_kind_quota"
25575            }),
25576            "expected source_handle budget drops, got {:?}",
25577            budget.dropped_by_budget
25578        );
25579        assert!(report.page.as_ref().unwrap().truncated);
25580        assert!(
25581            report
25582                .page
25583                .as_ref()
25584                .unwrap()
25585                .diagnostics
25586                .iter()
25587                .any(|diagnostic| diagnostic.contains("budget ranking signals")),
25588            "{:?}",
25589            report.page
25590        );
25591    }
25592
25593    #[test]
25594    fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
25595        let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
25596        let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
25597        for idx in 0..8 {
25598            let id = format!("far-{idx:02}");
25599            nodes.push(SubstrateGraphNode::new(
25600                id.clone(),
25601                "note",
25602                format!("aaa deeper row {idx}"),
25603            ));
25604            depth_by_id.insert(id, 6);
25605        }
25606
25607        let origin_ids = vec!["target".to_string()];
25608        let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
25609            &origin_ids,
25610            &BTreeMap::new(),
25611            nodes,
25612            Vec::new(),
25613            Some(3),
25614            Some(&depth_by_id),
25615            None,
25616        );
25617
25618        assert!(
25619            budgeted.nodes.iter().any(|node| node.id == "near"),
25620            "expected the shallow evidence row to outrank deeper rows, got {:?}",
25621            budgeted
25622                .nodes
25623                .iter()
25624                .map(|node| (&node.id, &node.label))
25625                .collect::<Vec<_>>()
25626        );
25627        assert!(
25628            budgeted.report.dropped_by_budget.iter().any(|drop| {
25629                drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
25630            }),
25631            "expected node quota drops, got {:?}",
25632            budgeted.report.dropped_by_budget
25633        );
25634        assert!(
25635            budgeted
25636                .report
25637                .diagnostics
25638                .iter()
25639                .any(|diagnostic| diagnostic.contains("depth")),
25640            "{:?}",
25641            budgeted.report.diagnostics
25642        );
25643    }
25644
25645    #[test]
25646    fn evidence_pagination_returns_next_cursor_when_truncated() {
25647        let mut nodes = vec![SubstrateGraphNode::new(
25648            "target".to_string(),
25649            "backlog_item",
25650            "target item".to_string(),
25651        )];
25652        let mut depth_by_id = BTreeMap::new();
25653        depth_by_id.insert("target".to_string(), 0);
25654        for idx in 0..20 {
25655            let id = format!("ev-{idx}");
25656            nodes.push(
25657                SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
25658                    .with_property("detail", "x".repeat(400)),
25659            );
25660            depth_by_id.insert(id, 1);
25661        }
25662        let origin_ids = vec!["target".to_string()];
25663        let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
25664            &origin_ids,
25665            &BTreeMap::new(),
25666            nodes.clone(),
25667            Vec::new(),
25668            Some(3),
25669            Some(&depth_by_id),
25670            None,
25671        );
25672        assert!(
25673            first_page.truncated,
25674            "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
25675            first_page.nodes.len(),
25676            first_page.report.candidate_nodes
25677        );
25678        assert!(
25679            first_page.next_cursor.is_some(),
25680            "expected next_cursor when truncated"
25681        );
25682        let cursor = first_page.next_cursor.unwrap();
25683        assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
25684        let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
25685        let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
25686            &origin_ids,
25687            &BTreeMap::new(),
25688            nodes.clone(),
25689            Vec::new(),
25690            Some(3),
25691            Some(&depth_by_id),
25692            Some(&cursor),
25693        );
25694        let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
25695        let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
25696        assert!(
25697            overlap.is_empty(),
25698            "pages should not overlap, but found shared ids: {overlap:?}"
25699        );
25700        assert!(
25701            second_page
25702                .report
25703                .diagnostics
25704                .iter()
25705                .any(|d| d.contains("cursor skipped")),
25706            "expected cursor skip diagnostic, got {:?}",
25707            second_page.report.diagnostics
25708        );
25709    }
25710
25711    #[test]
25712    fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
25713        let mut nodes = vec![SubstrateGraphNode::new(
25714            "target".to_string(),
25715            "backlog_item",
25716            "target item".to_string(),
25717        )];
25718        let mut depth_by_id = BTreeMap::new();
25719        depth_by_id.insert("target".to_string(), 0);
25720        for idx in 0..3 {
25721            let id = format!("ev-{idx}");
25722            nodes.push(SubstrateGraphNode::new(
25723                id.clone(),
25724                "source_handle",
25725                format!("evidence row {idx}"),
25726            ));
25727            depth_by_id.insert(id, 1);
25728        }
25729        let origin_ids = vec!["target".to_string()];
25730        let result = graph_db_apply_output_budget_with_depths_and_cursor(
25731            &origin_ids,
25732            &BTreeMap::new(),
25733            nodes,
25734            Vec::new(),
25735            None,
25736            Some(&depth_by_id),
25737            None,
25738        );
25739        assert!(
25740            !result.truncated,
25741            "expected no truncation with small candidate set and default budget"
25742        );
25743        assert!(
25744            result.next_cursor.is_none(),
25745            "expected no next_cursor when not truncated"
25746        );
25747    }
25748
25749    #[test]
25750    fn evidence_pagination_invalid_cursor_returns_first_page() {
25751        let mut nodes = vec![SubstrateGraphNode::new(
25752            "target".to_string(),
25753            "backlog_item",
25754            "target item".to_string(),
25755        )];
25756        let mut depth_by_id = BTreeMap::new();
25757        depth_by_id.insert("target".to_string(), 0);
25758        for idx in 0..5 {
25759            let id = format!("ev-{idx}");
25760            nodes.push(SubstrateGraphNode::new(
25761                id.clone(),
25762                "source_handle",
25763                format!("evidence row {idx}"),
25764            ));
25765            depth_by_id.insert(id, 1);
25766        }
25767        let origin_ids = vec!["target".to_string()];
25768        let result = graph_db_apply_output_budget_with_depths_and_cursor(
25769            &origin_ids,
25770            &BTreeMap::new(),
25771            nodes.clone(),
25772            Vec::new(),
25773            None,
25774            Some(&depth_by_id),
25775            Some("nonexistent-id"),
25776        );
25777        assert!(
25778            result
25779                .report
25780                .diagnostics
25781                .iter()
25782                .any(|d| d.contains("cursor skipped 0")),
25783            "invalid cursor should skip 0 candidates, got {:?}",
25784            result.report.diagnostics
25785        );
25786    }
25787
25788    #[test]
25789    fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25790        let dir = setup_traversal_project();
25791        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25792        let graph_db = dir.path().join(".tsift/graph.db");
25793        let _lock = hold_rollback_journal_lock(&graph_db);
25794
25795        let report =
25796            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25797                .unwrap();
25798
25799        assert_eq!(report.status, "current");
25800        assert_eq!(
25801            report.recovery,
25802            Some(index::ReadOnlyRecovery::SnapshotFallback)
25803        );
25804        assert!(
25805            report
25806                .warnings
25807                .iter()
25808                .any(|warning| warning.contains("rollback-journal lock")),
25809            "expected rollback-journal recovery warning, got {:?}",
25810            report.warnings
25811        );
25812    }
25813
25814    #[test]
25815    fn graph_db_status_copies_wal_sidecars_when_locked() {
25816        let dir = setup_traversal_project();
25817        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25818        let graph_db = dir.path().join(".tsift/graph.db");
25819        let _lock = hold_wal_database_lock(&graph_db);
25820
25821        let report =
25822            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25823                .unwrap();
25824
25825        assert_eq!(report.status, "current");
25826        assert_eq!(
25827            report.recovery,
25828            Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25829        );
25830        assert!(
25831            report
25832                .warnings
25833                .iter()
25834                .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25835            "expected WAL recovery warning, got {:?}",
25836            report.warnings
25837        );
25838    }
25839
25840    #[test]
25841    fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
25842        let dir = setup_traversal_project();
25843        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25844        let graph_db = dir.path().join(".tsift/graph.db");
25845        let _lock = hold_rollback_journal_lock(&graph_db);
25846
25847        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25848        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25849        report.finalize();
25850
25851        assert_eq!(report.status, "ok");
25852        assert!(!report.fail_closed);
25853        let recovery_check = report
25854            .checks
25855            .iter()
25856            .find(|check| check.name == "sqlite_graph_db_read_recovery")
25857            .expect("doctor should include read recovery diagnostic");
25858        assert_eq!(recovery_check.status, "recovered");
25859        assert!(
25860            recovery_check
25861                .diagnostics
25862                .iter()
25863                .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
25864            "expected rollback-journal recovery diagnostic, got {:?}",
25865            recovery_check.diagnostics
25866        );
25867    }
25868
25869    #[test]
25870    fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
25871        let dir = setup_traversal_project();
25872        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25873        let graph_db = dir.path().join(".tsift/graph.db");
25874        let _lock = hold_wal_database_lock(&graph_db);
25875
25876        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25877        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25878        report.finalize();
25879
25880        assert_eq!(report.status, "ok");
25881        assert!(!report.fail_closed);
25882        let recovery_check = report
25883            .checks
25884            .iter()
25885            .find(|check| check.name == "sqlite_graph_db_read_recovery")
25886            .expect("doctor should include WAL read recovery diagnostic");
25887        assert_eq!(recovery_check.status, "recovered");
25888        assert!(
25889            recovery_check
25890                .diagnostics
25891                .iter()
25892                .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
25893            "expected WAL recovery diagnostic, got {:?}",
25894            recovery_check.diagnostics
25895        );
25896    }
25897
25898    #[test]
25899    fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
25900        let dir = setup_traversal_project();
25901        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25902        let artifact = dir.path().join("graph.db.gz");
25903
25904        let exported =
25905            commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
25906                .unwrap();
25907        let exported_projection_version = exported.freshness.projection_version.clone();
25908        let exported_content_hash = exported.freshness.content_hash.clone();
25909        let exported_source_watermark = exported.freshness.source_watermark.clone();
25910        let exported_nodes = exported.counts.nodes;
25911        let exported_edges = exported.counts.edges;
25912        assert_eq!(exported.operation, "snapshot-export");
25913        assert!(exported.status.starts_with("exported"));
25914        assert!(artifact.exists());
25915        assert!(exported.artifact_bytes > 0);
25916        assert_eq!(exported.compression, "gzip");
25917
25918        fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
25919
25920        let imported =
25921            commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
25922                .unwrap();
25923        assert_eq!(imported.operation, "snapshot-import");
25924        assert!(imported.status.starts_with("imported"));
25925        assert_eq!(
25926            imported.freshness.projection_version,
25927            exported_projection_version
25928        );
25929        assert_eq!(imported.freshness.content_hash, exported_content_hash);
25930        assert_eq!(
25931            imported.freshness.source_watermark,
25932            exported_source_watermark
25933        );
25934        assert_eq!(imported.counts.nodes, exported_nodes);
25935        assert_eq!(imported.counts.edges, exported_edges);
25936        assert!(dir.path().join(".tsift/graph.db").exists());
25937    }
25938
25939    #[test]
25940    fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
25941        let dir = setup_traversal_project();
25942        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25943        let graph_db = dir.path().join(".tsift/graph.db");
25944        let _lock = hold_wal_database_lock(&graph_db);
25945
25946        let err = match commands::infra::graph_db_snapshot_export_report(
25947            dir.path(),
25948            None,
25949            &dir.path().join("graph.db.gz"),
25950            false,
25951        ) {
25952            Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
25953            Err(err) => err,
25954        };
25955
25956        // The resilient open succeeds via the recovery fallback in this WAL-lock
25957        // case, so the live-lock signal surfaces at the recovery gate. Its
25958        // wording is now unified with the `map_live_lock` open-path diagnostic so
25959        // the operator gets the same actionable guidance regardless of which gate
25960        // trips (#tsreviewcleanup).
25961        let message = err.to_string();
25962        assert!(
25963            message.contains("recovered path")
25964                && message.contains("database is locked")
25965                && message.contains("wait for it to finish before retrying the export"),
25966            "expected unified live-lock recovery diagnostic, got {err:#}"
25967        );
25968    }
25969
25970    #[test]
25971    fn graph_db_snapshot_clean_export_maps_database_locked_to_live_lock_diagnostic() {
25972        let dir = setup_traversal_project();
25973        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25974        let graph_db = dir.path().join(".tsift/graph.db");
25975
25976        // Hold a plain EXCLUSIVE lock with no recovery sidecar so the export
25977        // clears the recovery fail-closed gate and reaches VACUUM INTO, which
25978        // then fails with a raw SQLite "database is locked".
25979        let blocker = Connection::open(&graph_db).unwrap();
25980        blocker
25981            .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
25982            .unwrap();
25983        assert!(!substrate::rollback_journal_path(&graph_db).exists());
25984
25985        let clean_path = dir.path().join("graph-clean-export.db");
25986        let err = match commands::infra::graph_db_snapshot_clean_export_copy(&graph_db, &clean_path)
25987        {
25988            Ok(bytes) => panic!("expected export to fail under live lock, got {bytes} bytes"),
25989            Err(err) => err,
25990        };
25991
25992        let message = err.to_string();
25993        assert!(
25994            message.contains("concurrent graph-db refresh or snapshot-import is in progress"),
25995            "expected actionable live-lock diagnostic, got {err:#}"
25996        );
25997        assert!(
25998            message.contains("wait for it to finish before retrying"),
25999            "expected retry guidance, got {err:#}"
26000        );
26001        // The raw SQLite phrasing must not leak as the surfaced top-level error.
26002        assert!(
26003            !message.contains("creating clean graph-db export copy"),
26004            "live-lock case must not surface the generic VACUUM context, got {err:#}"
26005        );
26006
26007        drop(blocker);
26008    }
26009
26010    #[test]
26011    fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
26012        let dir = setup_traversal_project();
26013        let session = dir.path().join("tasks/software/tsift.md");
26014        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
26015        let graph_db = dir.path().join(".tsift/graph.db");
26016        let _lock = hold_rollback_journal_lock(&graph_db);
26017
26018        let result = cmd_graph_db(
26019            &session,
26020            None,
26021            GraphDbBackend::Sqlite,
26022            None,
26023            GraphDbQuery::Evidence {
26024                target: "kgnv".to_string(),
26025                depth: 3,
26026                limit: 8,
26027                cursor: None,
26028            },
26029            OutputFormat {
26030                json_output: false,
26031                compact: true,
26032                pretty: false,
26033                terse: false,
26034                ultra_terse: false,
26035                schema: false,
26036                envelope: false,
26037            },
26038        );
26039
26040        assert!(result.is_ok());
26041    }
26042
26043    fn current_graph_db_freshness() -> GraphDbFreshnessReport {
26044        GraphDbFreshnessReport {
26045            status: "current".to_string(),
26046            fail_closed: false,
26047            projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
26048            content_hash: Some("fixture".to_string()),
26049            source_watermark: None,
26050            diagnostics: Vec::new(),
26051        }
26052    }
26053
26054    #[test]
26055    fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
26056        let dir = setup_traversal_project();
26057        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26058        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
26059        let stale = GraphDbFreshnessReport {
26060            status: "stale".to_string(),
26061            fail_closed: true,
26062            projection_version: Some("old-v0".to_string()),
26063            content_hash: None,
26064            source_watermark: None,
26065            diagnostics: vec!["projection content hash is missing".to_string()],
26066        };
26067
26068        let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
26069            root: dir.path(),
26070            scope: None,
26071            backend: "sqlite",
26072            target: "kgnv",
26073            preferred_path: None,
26074            depth: 3,
26075            limit: 8,
26076            cursor: None,
26077            store: &store,
26078            freshness: stale,
26079            warnings: Vec::new(),
26080        }) {
26081            Ok(_) => panic!("stale graph freshness should fail closed"),
26082            Err(err) => err,
26083        };
26084        let message = err.to_string();
26085        assert!(message.contains("failed closed"), "{message}");
26086        assert!(message.contains("graph-db --path"), "{message}");
26087        assert!(message.contains("refresh --json"), "{message}");
26088    }
26089
26090    fn paged_graph_ids(
26091        store: &impl GraphStore,
26092        cursor: Option<&str>,
26093    ) -> (Vec<String>, GraphDbPageReport) {
26094        let report = graph_db_report_from_store(
26095            Path::new("."),
26096            None,
26097            "fixture",
26098            GraphDbQuery::Kind {
26099                kind: "backlog".to_string(),
26100                cursor: cursor.map(str::to_string),
26101                limit: Some(2),
26102                property_filters: vec!["phase=open".to_string()],
26103            },
26104            store,
26105            current_graph_db_freshness(),
26106            Vec::new(),
26107        )
26108        .unwrap();
26109        (
26110            report.nodes.iter().map(|node| node.id.clone()).collect(),
26111            report.page.unwrap(),
26112        )
26113    }
26114
26115    #[test]
26116    fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
26117        let nodes = (0..5)
26118            .map(|idx| {
26119                let phase = if idx == 1 { "closed" } else { "open" };
26120                SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
26121                    .with_property("phase", phase)
26122            })
26123            .collect::<Vec<_>>();
26124        let projection = GraphProjection {
26125            nodes,
26126            edges: Vec::new(),
26127        };
26128        let sqlite = SqliteGraphStore::in_memory().unwrap();
26129        projection.upsert_into(&sqlite).unwrap();
26130        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
26131        projection.upsert_into(&convex).unwrap();
26132
26133        let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
26134        let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
26135        assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
26136        assert_eq!(sqlite_first_ids, convex_first_ids);
26137        assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
26138        assert!(sqlite_first_page.truncated);
26139        assert_eq!(
26140            sqlite_first_page.returned_nodes,
26141            convex_first_page.returned_nodes
26142        );
26143        assert_eq!(
26144            sqlite_first_page.property_filters,
26145            convex_first_page.property_filters
26146        );
26147        assert!(
26148            sqlite_first_page
26149                .diagnostics
26150                .iter()
26151                .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
26152            "expected SQLite kind query plan diagnostics, got {:?}",
26153            sqlite_first_page.diagnostics
26154        );
26155
26156        let cursor = sqlite_first_page.next_cursor.as_deref();
26157        let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
26158        let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
26159        assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
26160        assert_eq!(sqlite_next_ids, convex_next_ids);
26161        assert_eq!(sqlite_next_page.next_cursor, None);
26162        assert!(!sqlite_next_page.truncated);
26163        assert_eq!(
26164            sqlite_next_page.returned_nodes,
26165            convex_next_page.returned_nodes
26166        );
26167        assert_eq!(
26168            sqlite_next_page.property_filters,
26169            convex_next_page.property_filters
26170        );
26171    }
26172
26173    #[test]
26174    fn traversal_shortest_path_crosses_artifacts_and_symbols() {
26175        let dir = setup_traversal_project();
26176        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26177        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
26178        let main = resolve_traversal_node(&graph, "main").unwrap();
26179
26180        let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
26181        assert_eq!(path.first(), Some(&backlog.handle));
26182        assert_eq!(path.last(), Some(&main.handle));
26183        assert!(
26184            path.len() >= 3,
26185            "expected backlog -> symbol -> main, got {path:?}"
26186        );
26187    }
26188
26189    #[test]
26190    fn traversal_report_recommends_next_bugfix_nodes() {
26191        let dir = setup_traversal_project();
26192        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26193        let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
26194
26195        assert_eq!(report.mode, "neighborhood");
26196        assert!(
26197            report
26198                .recommendations
26199                .iter()
26200                .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
26201            "expected helper recommendation, got {:?}",
26202            report.recommendations
26203        );
26204        assert!(
26205            !report.exploration.source_windows.is_empty(),
26206            "expected exploration source windows"
26207        );
26208        assert!(
26209            report
26210                .exploration
26211                .no_reread_guidance
26212                .contains("avoid whole-file reads")
26213        );
26214    }
26215
26216    #[test]
26217    fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
26218        let dir = setup_traversal_project();
26219        std::thread::sleep(std::time::Duration::from_millis(50));
26220        std::fs::write(
26221            dir.path().join("main.rs"),
26222            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26223        )
26224        .unwrap();
26225
26226        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26227
26228        assert!(
26229            graph
26230                .warnings
26231                .iter()
26232                .any(|warning| warning.contains("index refreshed")
26233                    && warning.contains("graph traversal packet")),
26234            "expected refresh diagnostic, got {:?}",
26235            graph.warnings
26236        );
26237        assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
26238
26239        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26240        let summary = db.compute_changes(dir.path()).unwrap();
26241        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26242    }
26243
26244    #[test]
26245    fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
26246        let dir = setup_traversal_project();
26247        let db_path = dir.path().join(".tsift/index.db");
26248        let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
26249        std::thread::sleep(std::time::Duration::from_millis(50));
26250        std::fs::write(
26251            dir.path().join("main.rs"),
26252            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26253        )
26254        .unwrap();
26255
26256        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26257        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
26258
26259        assert!(
26260            graph
26261                .warnings
26262                .iter()
26263                .any(|warning| warning.contains("falling back to raw source file nodes")),
26264            "expected raw-source fallback diagnostic, got {:?}",
26265            graph.warnings
26266        );
26267        assert!(
26268            file.detail
26269                .as_deref()
26270                .is_some_and(|detail| detail.contains("raw source fallback")),
26271            "expected raw-source detail, got {:?}",
26272            file.detail
26273        );
26274        assert!(
26275            file.expand.contains("source-read"),
26276            "expected source-read fallback command, got {}",
26277            file.expand
26278        );
26279        assert!(
26280            resolve_traversal_node(&graph, "helper").is_none(),
26281            "stale symbol evidence should be skipped when refresh is blocked"
26282        );
26283    }
26284
26285    #[test]
26286    fn traversal_cmd_supports_json_and_html_outputs() {
26287        let dir = setup_traversal_project();
26288        cmd_traverse(
26289            Some("#kgnv"),
26290            Some("main"),
26291            dir.path(),
26292            None,
26293            1,
26294            50,
26295            TraverseFormat::Json,
26296            false,
26297            false,
26298            false,
26299            None,
26300        )
26301        .unwrap();
26302        cmd_traverse(
26303            None,
26304            None,
26305            dir.path(),
26306            None,
26307            1,
26308            50,
26309            TraverseFormat::Html,
26310            false,
26311            false,
26312            false,
26313            None,
26314        )
26315        .unwrap();
26316    }
26317
26318    #[test]
26319    fn traversal_html_renders_inline_graph_visualization() {
26320        let dir = setup_traversal_project();
26321        seed_traversal_semantic_summaries(dir.path());
26322        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26323        let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
26324        let html = traversal_report_html(&report).unwrap();
26325
26326        assert!(html.contains("id=\"graph-canvas\""));
26327        assert!(html.contains("semantic_concept"));
26328        assert!(html.contains("graph navigation"));
26329        assert!(html.contains("JSON.parse"));
26330    }
26331
26332    #[test]
26333    fn compact_helpers_trim_scores_and_snippets() {
26334        assert_eq!(format_score(0.12345, true), "0.12");
26335        assert_eq!(format_score(0.12345, false), "0.1235");
26336        let snippet = compact_snippet("    first line with useful context\nsecond");
26337        assert_eq!(snippet.as_deref(), Some("first line with useful context"));
26338    }
26339
26340    #[test]
26341    fn compact_members_caps_list() {
26342        let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
26343            .iter()
26344            .map(|n| graph::CommunityMember::new(*n))
26345            .collect();
26346        assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
26347    }
26348
26349    #[test]
26350    fn abbreviate_kind_maps_common_kinds() {
26351        assert_eq!(abbreviate_kind("function"), "fn");
26352        assert_eq!(abbreviate_kind("method"), "meth");
26353        assert_eq!(abbreviate_kind("class"), "cls");
26354        assert_eq!(abbreviate_kind("interface"), "iface");
26355        assert_eq!(abbreviate_kind("type_alias"), "type");
26356        assert_eq!(abbreviate_kind("data_class"), "data_cls");
26357        assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
26358        assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
26359        assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
26360        assert_eq!(abbreviate_kind("object"), "obj");
26361        assert_eq!(abbreviate_kind("heading"), "h");
26362        assert_eq!(abbreviate_kind("code_block"), "code");
26363        // short kinds pass through
26364        assert_eq!(abbreviate_kind("struct"), "struct");
26365        assert_eq!(abbreviate_kind("trait"), "trait");
26366        assert_eq!(abbreviate_kind("enum"), "enum");
26367        assert_eq!(abbreviate_kind("const"), "const");
26368        assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
26369    }
26370
26371    #[test]
26372    fn abbreviate_match_type_maps_search_types() {
26373        assert_eq!(abbreviate_match_type("exact_name"), "exact");
26374        assert_eq!(abbreviate_match_type("partial_tags"), "partial");
26375        assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
26376        assert_eq!(abbreviate_match_type("other_type"), "other_type");
26377    }
26378
26379    #[test]
26380    fn explain_compact_groups_edges_by_file() {
26381        let edges = vec![
26382            index::StoredEdge {
26383                caller_file: "src/main.rs".to_string(),
26384                caller_name: "main".to_string(),
26385                caller_line: 1,
26386                callee_name: "helper".to_string(),
26387                call_site_line: 2,
26388                tagpath_handle: None,
26389            },
26390            index::StoredEdge {
26391                caller_file: "src/main.rs".to_string(),
26392                caller_name: "main".to_string(),
26393                caller_line: 1,
26394                callee_name: "render".to_string(),
26395                call_site_line: 3,
26396                tagpath_handle: None,
26397            },
26398        ];
26399        let lines = format_edge_groups(&edges, false);
26400        assert_eq!(lines, vec!["  src/main.rs (2): helper, render"]);
26401    }
26402
26403    #[test]
26404    fn search_hit_groups_preserve_file_counts_and_samples() {
26405        let dir = tempfile::tempdir().unwrap();
26406        let root = dir.path();
26407        let main_rs = root.join("src/main.rs");
26408        fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
26409        fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
26410        let freshness = exact_search_file_timestamp(&main_rs);
26411        let hits = vec![
26412            sift::SearchHit {
26413                artifact_id: "a".to_string(),
26414                artifact_kind: sift::ContextArtifactKind::File,
26415                path: main_rs.display().to_string(),
26416                rank: 1,
26417                score: 10.0,
26418                confidence: sift::ScoreConfidence::High,
26419                location: Some("line 3".to_string()),
26420                snippet: "claudescore-3 anchor".to_string(),
26421                provenance: sift::ArtifactProvenance {
26422                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26423                    source: "ripgrep -F".to_string(),
26424                    synthetic: false,
26425                },
26426                freshness: freshness.clone(),
26427                budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
26428            },
26429            sift::SearchHit {
26430                artifact_id: "b".to_string(),
26431                artifact_kind: sift::ContextArtifactKind::File,
26432                path: main_rs.display().to_string(),
26433                rank: 2,
26434                score: 9.0,
26435                confidence: sift::ScoreConfidence::High,
26436                location: Some("line 7".to_string()),
26437                snippet: "claudescore-3 follow-up".to_string(),
26438                provenance: sift::ArtifactProvenance {
26439                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26440                    source: "ripgrep -F".to_string(),
26441                    synthetic: false,
26442                },
26443                freshness: freshness.clone(),
26444                budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
26445            },
26446            sift::SearchHit {
26447                artifact_id: "c".to_string(),
26448                artifact_kind: sift::ContextArtifactKind::File,
26449                path: main_rs.display().to_string(),
26450                rank: 3,
26451                score: 8.0,
26452                confidence: sift::ScoreConfidence::High,
26453                location: Some("line 9".to_string()),
26454                snippet: "claudescore-3 tail".to_string(),
26455                provenance: sift::ArtifactProvenance {
26456                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26457                    source: "ripgrep -F".to_string(),
26458                    synthetic: false,
26459                },
26460                freshness,
26461                budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
26462            },
26463        ];
26464
26465        let groups = group_search_hits(&hits, root, false);
26466        assert_eq!(groups.len(), 1);
26467        assert_eq!(groups[0].path, "src/main.rs");
26468        assert_eq!(groups[0].hits, 3);
26469        assert_eq!(
26470            groups[0].samples,
26471            vec![
26472                "line 3: claudescore-3 anchor".to_string(),
26473                "line 7: claudescore-3 follow-up".to_string()
26474            ]
26475        );
26476        assert!(should_collapse_search_hits(&hits, root, false));
26477    }
26478
26479    #[test]
26480    fn dense_edge_groups_trigger_collapse() {
26481        let edges = vec![
26482            index::StoredEdge {
26483                caller_file: "src/main.rs".to_string(),
26484                caller_name: "main".to_string(),
26485                caller_line: 1,
26486                callee_name: "helper".to_string(),
26487                call_site_line: 2,
26488                tagpath_handle: None,
26489            },
26490            index::StoredEdge {
26491                caller_file: "src/main.rs".to_string(),
26492                caller_name: "beta".to_string(),
26493                caller_line: 5,
26494                callee_name: "helper".to_string(),
26495                call_site_line: 6,
26496                tagpath_handle: None,
26497            },
26498            index::StoredEdge {
26499                caller_file: "src/main.rs".to_string(),
26500                caller_name: "gamma".to_string(),
26501                caller_line: 9,
26502                callee_name: "helper".to_string(),
26503                call_site_line: 10,
26504                tagpath_handle: None,
26505            },
26506        ];
26507        assert!(should_collapse_edge_groups(&edges));
26508    }
26509
26510    // --- workspace indexing ---
26511
26512    fn setup_workspace() -> tempfile::TempDir {
26513        let dir = tempfile::tempdir().unwrap();
26514        let root = dir.path();
26515        std::fs::write(
26516            root.join(".gitmodules"),
26517            r#"[submodule "src/alpha"]
26518	path = src/alpha
26519	url = https://example.com/alpha
26520[submodule "src/beta"]
26521	path = src/beta
26522	url = https://example.com/beta
26523"#,
26524        )
26525        .unwrap();
26526        let alpha = root.join("src/alpha");
26527        let beta = root.join("src/beta");
26528        std::fs::create_dir_all(&alpha).unwrap();
26529        std::fs::create_dir_all(&beta).unwrap();
26530        std::fs::write(
26531            alpha.join("lib.rs"),
26532            "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
26533        )
26534        .unwrap();
26535        std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
26536        dir
26537    }
26538
26539    fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
26540        let dir = tempfile::tempdir().unwrap();
26541        let root = dir.path();
26542        std::fs::write(
26543            root.join(".gitmodules"),
26544            r#"[submodule "pkg/app/foo"]
26545	path = pkg/app/foo
26546	url = https://example.com/pkg-app-foo
26547[submodule "vendor/foo"]
26548	path = vendor/foo
26549	url = https://example.com/vendor-foo
26550"#,
26551        )
26552        .unwrap();
26553        let pkg_foo = root.join("pkg/app/foo");
26554        let vendor_foo = root.join("vendor/foo");
26555        std::fs::create_dir_all(&pkg_foo).unwrap();
26556        std::fs::create_dir_all(&vendor_foo).unwrap();
26557        std::fs::write(
26558            pkg_foo.join("lib.rs"),
26559            "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
26560        )
26561        .unwrap();
26562        std::fs::write(
26563            vendor_foo.join("lib.rs"),
26564            "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
26565        )
26566        .unwrap();
26567        dir
26568    }
26569
26570    #[test]
26571    fn workspace_index_creates_per_submodule_dbs() {
26572        let dir = setup_workspace();
26573        cmd_index(
26574            dir.path(),
26575            false,
26576            false,
26577            false,
26578            false,
26579            false,
26580            true,
26581            None,
26582            false,
26583            false,
26584            false,
26585            false,
26586            false,
26587            false,
26588        )
26589        .unwrap();
26590        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26591        assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
26592    }
26593
26594    #[test]
26595    fn workspace_index_single_submodule() {
26596        let dir = setup_workspace();
26597        cmd_index(
26598            dir.path(),
26599            false,
26600            false,
26601            false,
26602            false,
26603            false,
26604            false,
26605            Some("alpha"),
26606            false,
26607            false,
26608            false,
26609            false,
26610            false,
26611            false,
26612        )
26613        .unwrap();
26614        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26615        assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
26616    }
26617
26618    #[test]
26619    fn workspace_index_single_submodule_errors_on_unknown_scope() {
26620        let dir = setup_workspace();
26621
26622        let err = cmd_index(
26623            dir.path(),
26624            false,
26625            false,
26626            false,
26627            false,
26628            false,
26629            false,
26630            Some("missing"),
26631            false,
26632            false,
26633            false,
26634            false,
26635            false,
26636            false,
26637        )
26638        .unwrap_err();
26639
26640        let msg = err.to_string();
26641        assert!(msg.contains("unknown scope `missing`"));
26642        assert!(msg.contains("Available scopes: alpha, beta"));
26643        assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
26644    }
26645
26646    #[test]
26647    fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
26648        let dir = setup_workspace_with_duplicate_leaf_names();
26649        cmd_index(
26650            dir.path(),
26651            false,
26652            false,
26653            false,
26654            false,
26655            false,
26656            true,
26657            None,
26658            false,
26659            false,
26660            false,
26661            false,
26662            false,
26663            false,
26664        )
26665        .unwrap();
26666
26667        assert!(
26668            dir.path()
26669                .join(".tsift/indexes/pkg/app/foo/index.db")
26670                .exists()
26671        );
26672        assert!(
26673            dir.path()
26674                .join(".tsift/indexes/vendor/foo/index.db")
26675                .exists()
26676        );
26677    }
26678
26679    #[test]
26680    fn federated_search_across_submodules() {
26681        let dir = setup_workspace();
26682        cmd_index(
26683            dir.path(),
26684            false,
26685            false,
26686            false,
26687            false,
26688            false,
26689            true,
26690            None,
26691            false,
26692            false,
26693            false,
26694            false,
26695            false,
26696            false,
26697        )
26698        .unwrap();
26699        let (hits, _diag) = federated_symbol_search(
26700            dir.path(),
26701            "alpha_helper",
26702            10,
26703            &TagpathSearchOpts {
26704                no_tagpath: true,
26705                strict: false,
26706            },
26707        )
26708        .unwrap();
26709        assert!(
26710            !hits.is_empty(),
26711            "should find alpha_helper via federated search"
26712        );
26713    }
26714
26715    #[test]
26716    fn federated_search_respects_isolation() {
26717        let dir = setup_workspace();
26718        let tsift_dir = dir.path().join(".tsift");
26719        std::fs::create_dir_all(&tsift_dir).unwrap();
26720        std::fs::write(
26721            tsift_dir.join("config.toml"),
26722            r#"
26723[overrides.alpha]
26724tier = "isolated"
26725"#,
26726        )
26727        .unwrap();
26728        cmd_index(
26729            dir.path(),
26730            false,
26731            false,
26732            false,
26733            false,
26734            false,
26735            true,
26736            None,
26737            false,
26738            false,
26739            false,
26740            false,
26741            false,
26742            false,
26743        )
26744        .unwrap();
26745        let (hits, _diag) = federated_symbol_search(
26746            dir.path(),
26747            "alpha_helper",
26748            10,
26749            &TagpathSearchOpts {
26750                no_tagpath: true,
26751                strict: false,
26752            },
26753        )
26754        .unwrap();
26755        assert!(
26756            hits.is_empty(),
26757            "isolated submodule should not appear in federated search"
26758        );
26759    }
26760
26761    #[test]
26762    fn federated_lexical_search_respects_isolation() {
26763        let dir = setup_workspace();
26764        let tsift_dir = dir.path().join(".tsift");
26765        std::fs::create_dir_all(&tsift_dir).unwrap();
26766        std::fs::write(
26767            tsift_dir.join("config.toml"),
26768            r#"
26769[overrides.alpha]
26770tier = "isolated"
26771"#,
26772        )
26773        .unwrap();
26774        cmd_index(
26775            dir.path(),
26776            false,
26777            false,
26778            false,
26779            false,
26780            false,
26781            true,
26782            None,
26783            false,
26784            false,
26785            false,
26786            false,
26787            false,
26788            false,
26789        )
26790        .unwrap();
26791
26792        let response = federated_sift_search(
26793            dir.path(),
26794            &dir.path().join(".tsift/search-cache"),
26795            "fn",
26796            10,
26797            0,
26798            "lexical",
26799            None,
26800        )
26801        .unwrap();
26802
26803        assert!(
26804            !response.hits.is_empty(),
26805            "shared scopes should still contribute lexical hits"
26806        );
26807        assert!(
26808            response
26809                .hits
26810                .iter()
26811                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26812            "isolated scope should not leak lexical hits: {:?}",
26813            response.hits
26814        );
26815    }
26816
26817    #[test]
26818    fn federated_lexical_search_respects_private_tier() {
26819        let dir = setup_workspace();
26820        let tsift_dir = dir.path().join(".tsift");
26821        std::fs::create_dir_all(&tsift_dir).unwrap();
26822        std::fs::write(
26823            tsift_dir.join("config.toml"),
26824            r#"
26825[overrides.alpha]
26826tier = "private"
26827"#,
26828        )
26829        .unwrap();
26830        cmd_index(
26831            dir.path(),
26832            false,
26833            false,
26834            false,
26835            false,
26836            false,
26837            true,
26838            None,
26839            false,
26840            false,
26841            false,
26842            false,
26843            false,
26844            false,
26845        )
26846        .unwrap();
26847
26848        let response = federated_sift_search(
26849            dir.path(),
26850            &dir.path().join(".tsift/search-cache"),
26851            "fn",
26852            10,
26853            0,
26854            "lexical",
26855            None,
26856        )
26857        .unwrap();
26858
26859        assert!(
26860            !response.hits.is_empty(),
26861            "shared scopes should still contribute lexical hits"
26862        );
26863        assert!(
26864            response
26865                .hits
26866                .iter()
26867                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26868            "private scope should not leak lexical hits: {:?}",
26869            response.hits
26870        );
26871    }
26872
26873    #[test]
26874    fn scoped_search_finds_submodule_symbols() {
26875        let dir = setup_workspace();
26876        cmd_index(
26877            dir.path(),
26878            false,
26879            false,
26880            false,
26881            false,
26882            false,
26883            true,
26884            None,
26885            false,
26886            false,
26887            false,
26888            false,
26889            false,
26890            false,
26891        )
26892        .unwrap();
26893        let cfg = config::Config::load(dir.path()).unwrap();
26894        let db_path = cfg.db_path_for(dir.path(), "alpha");
26895        let db = index::IndexDb::open(&db_path).unwrap();
26896        let hits = db.symbol_search("alpha_main", 10).unwrap();
26897        assert!(!hits.is_empty());
26898        assert_eq!(hits[0].name, "alpha_main");
26899    }
26900
26901    #[test]
26902    fn scoped_search_cmd_errors_on_unknown_scope() {
26903        let dir = setup_workspace();
26904
26905        let err = cmd_search(
26906            "alpha_main".to_string(),
26907            Some(dir.path().to_path_buf()),
26908            5,
26909            Some("lexical".to_string()),
26910            Some("missing".to_string()),
26911            false,
26912            false,
26913            false,
26914            0,
26915            false,
26916            false,
26917            false,
26918            false,
26919            false,
26920            false,
26921            false,
26922        )
26923        .unwrap_err();
26924
26925        let msg = err.to_string();
26926        assert!(msg.contains("unknown scope `missing`"));
26927        assert!(msg.contains("Available scopes: alpha, beta"));
26928    }
26929
26930    #[test]
26931    fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
26932        let dir = setup_workspace_with_duplicate_leaf_names();
26933        cmd_index(
26934            dir.path(),
26935            false,
26936            false,
26937            false,
26938            false,
26939            false,
26940            true,
26941            None,
26942            false,
26943            false,
26944            false,
26945            false,
26946            false,
26947            false,
26948        )
26949        .unwrap();
26950
26951        let err = cmd_search(
26952            "vendor_only".to_string(),
26953            Some(dir.path().to_path_buf()),
26954            5,
26955            Some("lexical".to_string()),
26956            Some("foo".to_string()),
26957            false,
26958            false,
26959            false,
26960            0,
26961            false,
26962            false,
26963            false,
26964            false,
26965            false,
26966            false,
26967            false,
26968        )
26969        .unwrap_err();
26970
26971        let msg = err.to_string();
26972        assert!(msg.contains("ambiguous scope `foo`"));
26973        assert!(msg.contains("pkg/app/foo"));
26974        assert!(msg.contains("vendor/foo"));
26975    }
26976
26977    #[test]
26978    fn scoped_graph_query() {
26979        let dir = setup_workspace();
26980        cmd_index(
26981            dir.path(),
26982            false,
26983            false,
26984            false,
26985            false,
26986            false,
26987            true,
26988            None,
26989            false,
26990            false,
26991            false,
26992            false,
26993            false,
26994            false,
26995        )
26996        .unwrap();
26997        let cfg = config::Config::load(dir.path()).unwrap();
26998        let db_path = cfg.db_path_for(dir.path(), "alpha");
26999        let db = index::IndexDb::open(&db_path).unwrap();
27000        let callees = db.callees_of("alpha_main").unwrap();
27001        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
27002        assert!(names.contains(&"alpha_helper"));
27003    }
27004
27005    fn assert_workspace_query_requires_scope(err: anyhow::Error) {
27006        let msg = err.to_string();
27007        assert!(msg.contains("require `--scope <scope>`"), "{msg}");
27008        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27009        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27010        assert!(
27011            !msg.contains("no index found at"),
27012            "workspace query should fail with scope guidance, got: {msg}"
27013        );
27014    }
27015
27016    fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
27017        let msg = err.to_string();
27018        assert!(
27019            msg.contains("requires `--scope <scope>` or `--federated`"),
27020            "{msg}"
27021        );
27022        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27023        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27024        assert!(
27025            !msg.contains("autoindexing index"),
27026            "workspace search should fail before creating a shared root index: {msg}"
27027        );
27028    }
27029
27030    #[test]
27031    fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
27032        let dir = setup_workspace();
27033        cmd_index(
27034            dir.path(),
27035            false,
27036            false,
27037            false,
27038            false,
27039            false,
27040            true,
27041            None,
27042            false,
27043            false,
27044            false,
27045            false,
27046            false,
27047            false,
27048        )
27049        .unwrap();
27050
27051        let err = cmd_graph(
27052            "alpha_main",
27053            dir.path(),
27054            false,
27055            false,
27056            None,
27057            20,
27058            false,
27059            false,
27060            false,
27061            false,
27062            false,
27063            false,
27064            false,
27065            TagpathSearchOpts::default(),
27066        )
27067        .unwrap_err();
27068
27069        assert_workspace_query_requires_scope(err);
27070    }
27071
27072    #[test]
27073    fn graph_cmd_infers_scope_from_nested_workspace_path() {
27074        let dir = setup_workspace();
27075        cmd_index(
27076            dir.path(),
27077            false,
27078            false,
27079            false,
27080            false,
27081            false,
27082            true,
27083            None,
27084            false,
27085            false,
27086            false,
27087            false,
27088            false,
27089            false,
27090        )
27091        .unwrap();
27092        let nested = dir.path().join("src/alpha/nested");
27093        std::fs::create_dir_all(&nested).unwrap();
27094
27095        let result = cmd_graph(
27096            "alpha_main",
27097            &nested,
27098            false,
27099            false,
27100            None,
27101            20,
27102            false,
27103            false,
27104            false,
27105            false,
27106            false,
27107            false,
27108            false,
27109            TagpathSearchOpts::default(),
27110        );
27111
27112        assert!(result.is_ok());
27113    }
27114
27115    #[test]
27116    fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
27117        let dir = setup_workspace();
27118        cmd_index(
27119            dir.path(),
27120            false,
27121            false,
27122            false,
27123            false,
27124            false,
27125            true,
27126            None,
27127            false,
27128            false,
27129            false,
27130            false,
27131            false,
27132            false,
27133        )
27134        .unwrap();
27135
27136        let err = cmd_communities(
27137            dir.path(),
27138            None,
27139            1,
27140            10,
27141            false,
27142            false,
27143            false,
27144            false,
27145            false,
27146            false,
27147            TagpathSearchOpts::default(),
27148        )
27149        .unwrap_err();
27150
27151        assert_workspace_query_requires_scope(err);
27152    }
27153
27154    #[test]
27155    fn communities_cmd_infers_scope_from_nested_workspace_path() {
27156        let dir = setup_workspace();
27157        cmd_index(
27158            dir.path(),
27159            false,
27160            false,
27161            false,
27162            false,
27163            false,
27164            true,
27165            None,
27166            false,
27167            false,
27168            false,
27169            false,
27170            false,
27171            false,
27172        )
27173        .unwrap();
27174        let nested = dir.path().join("src/alpha/nested");
27175        std::fs::create_dir_all(&nested).unwrap();
27176
27177        let result = cmd_communities(
27178            &nested,
27179            None,
27180            1,
27181            10,
27182            false,
27183            false,
27184            false,
27185            false,
27186            false,
27187            false,
27188            TagpathSearchOpts::default(),
27189        );
27190
27191        assert!(result.is_ok());
27192    }
27193
27194    #[test]
27195    fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
27196        let dir = setup_workspace();
27197        cmd_index(
27198            dir.path(),
27199            false,
27200            false,
27201            false,
27202            false,
27203            false,
27204            true,
27205            None,
27206            false,
27207            false,
27208            false,
27209            false,
27210            false,
27211            false,
27212        )
27213        .unwrap();
27214
27215        let err = cmd_path(
27216            "alpha_main",
27217            "alpha_helper",
27218            dir.path(),
27219            None,
27220            false,
27221            false,
27222            false,
27223            false,
27224            false,
27225            TagpathSearchOpts::default(),
27226        )
27227        .unwrap_err();
27228
27229        assert_workspace_query_requires_scope(err);
27230    }
27231
27232    #[test]
27233    fn path_cmd_infers_scope_from_nested_workspace_path() {
27234        let dir = setup_workspace();
27235        cmd_index(
27236            dir.path(),
27237            false,
27238            false,
27239            false,
27240            false,
27241            false,
27242            true,
27243            None,
27244            false,
27245            false,
27246            false,
27247            false,
27248            false,
27249            false,
27250        )
27251        .unwrap();
27252        let nested = dir.path().join("src/alpha/nested");
27253        std::fs::create_dir_all(&nested).unwrap();
27254
27255        let result = cmd_path(
27256            "alpha_main",
27257            "alpha_helper",
27258            &nested,
27259            None,
27260            false,
27261            false,
27262            false,
27263            false,
27264            false,
27265            TagpathSearchOpts::default(),
27266        );
27267
27268        assert!(result.is_ok());
27269    }
27270
27271    #[test]
27272    fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27273        let dir = setup_graph_index();
27274        let db_path = dir.path().join(".tsift/index.db");
27275        let _lock = hold_rollback_journal_lock(&db_path);
27276
27277        let result = cmd_path(
27278            "main",
27279            "helper",
27280            dir.path(),
27281            None,
27282            false,
27283            false,
27284            false,
27285            false,
27286            false,
27287            TagpathSearchOpts::default(),
27288        );
27289
27290        assert!(result.is_ok());
27291    }
27292
27293    #[test]
27294    fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
27295        let dir = setup_workspace();
27296        cmd_index(
27297            dir.path(),
27298            false,
27299            false,
27300            false,
27301            false,
27302            false,
27303            true,
27304            None,
27305            false,
27306            false,
27307            false,
27308            false,
27309            false,
27310            false,
27311        )
27312        .unwrap();
27313
27314        let err = cmd_explain(
27315            "alpha_main",
27316            dir.path(),
27317            None,
27318            15,
27319            false,
27320            false,
27321            false,
27322            false,
27323            false,
27324            false,
27325            false,
27326            false,
27327        )
27328        .unwrap_err();
27329
27330        assert_workspace_query_requires_scope(err);
27331    }
27332
27333    #[test]
27334    fn explain_cmd_infers_scope_from_nested_workspace_path() {
27335        let dir = setup_workspace();
27336        cmd_index(
27337            dir.path(),
27338            false,
27339            false,
27340            false,
27341            false,
27342            false,
27343            true,
27344            None,
27345            false,
27346            false,
27347            false,
27348            false,
27349            false,
27350            false,
27351        )
27352        .unwrap();
27353        let nested = dir.path().join("src/alpha/nested");
27354        std::fs::create_dir_all(&nested).unwrap();
27355
27356        let result = cmd_explain(
27357            "alpha_main",
27358            &nested,
27359            None,
27360            15,
27361            false,
27362            false,
27363            false,
27364            false,
27365            false,
27366            false,
27367            false,
27368            false,
27369        );
27370
27371        assert!(result.is_ok());
27372    }
27373
27374    #[test]
27375    fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27376        let dir = setup_graph_index();
27377        let db_path = dir.path().join(".tsift/index.db");
27378        let _lock = hold_rollback_journal_lock(&db_path);
27379
27380        let result = cmd_explain(
27381            "main",
27382            dir.path(),
27383            None,
27384            15,
27385            false,
27386            false,
27387            false,
27388            false,
27389            false,
27390            false,
27391            false,
27392            false,
27393        );
27394
27395        assert!(result.is_ok());
27396    }
27397
27398    // --- community detection ---
27399
27400    #[test]
27401    fn community_detection_groups_related() {
27402        let dir = setup_graph_index();
27403        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27404        let edges = db.all_edges().unwrap();
27405        let result = graph::detect_communities(&edges);
27406        assert!(result.node_count > 0);
27407        assert!(!result.communities.is_empty());
27408    }
27409
27410    #[test]
27411    fn community_cmd_autoindexes_missing_index_by_default() {
27412        let dir = tempfile::tempdir().unwrap();
27413        let result = cmd_communities(
27414            dir.path(),
27415            None,
27416            2,
27417            10,
27418            false,
27419            false,
27420            false,
27421            false,
27422            false,
27423            false,
27424            TagpathSearchOpts::default(),
27425        );
27426
27427        assert!(result.is_ok());
27428        assert!(dir.path().join(".tsift/index.db").exists());
27429    }
27430
27431    // --- path ---
27432
27433    #[test]
27434    fn path_finds_connected_symbols() {
27435        let dir = setup_graph_index();
27436        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27437        let edges = db.all_edges().unwrap();
27438        let result = graph::shortest_path(&edges, "main", "helper");
27439        assert!(result.is_some());
27440        let path = result.unwrap();
27441        assert_eq!(path.hops, 1);
27442    }
27443
27444    #[test]
27445    fn path_returns_none_for_unknown() {
27446        let dir = setup_graph_index();
27447        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27448        let edges = db.all_edges().unwrap();
27449        assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
27450    }
27451
27452    #[test]
27453    fn path_cmd_autoindexes_missing_index_by_default() {
27454        let dir = tempfile::tempdir().unwrap();
27455        let result = cmd_path(
27456            "a",
27457            "b",
27458            dir.path(),
27459            None,
27460            false,
27461            false,
27462            false,
27463            false,
27464            false,
27465            TagpathSearchOpts::default(),
27466        );
27467
27468        assert!(result.is_ok());
27469        assert!(dir.path().join(".tsift/index.db").exists());
27470    }
27471
27472    // --- explain ---
27473
27474    #[test]
27475    fn explain_shows_symbol_info() {
27476        let dir = setup_graph_index();
27477        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27478        let symbols = db.symbol_info("main").unwrap();
27479        assert!(!symbols.is_empty());
27480        assert_eq!(symbols[0].name, "main");
27481        assert_eq!(symbols[0].kind, "function");
27482    }
27483
27484    #[test]
27485    fn explain_cmd_autoindexes_missing_index_by_default() {
27486        let dir = tempfile::tempdir().unwrap();
27487        let result = cmd_explain(
27488            "main",
27489            dir.path(),
27490            None,
27491            15,
27492            false,
27493            false,
27494            false,
27495            false,
27496            false,
27497            false,
27498            false,
27499            false,
27500        );
27501
27502        assert!(result.is_ok());
27503        assert!(dir.path().join(".tsift/index.db").exists());
27504    }
27505
27506    fn hold_write_lock(db_path: &std::path::Path) -> Connection {
27507        let conn = Connection::open(db_path).unwrap();
27508        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
27509        conn
27510    }
27511
27512    fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
27513        use fs4::fs_std::FileExt;
27514        use std::io::Write;
27515
27516        let mut file = std::fs::OpenOptions::new()
27517            .read(true)
27518            .write(true)
27519            .create(true)
27520            .truncate(false)
27521            .open(lock_path)
27522            .unwrap();
27523        assert!(file.try_lock_exclusive().unwrap());
27524        writeln!(file, "{}", std::process::id()).unwrap();
27525        file
27526    }
27527
27528    fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
27529        let conn = Connection::open(db_path).unwrap();
27530        conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
27531            .unwrap();
27532        std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
27533        conn
27534    }
27535
27536    fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
27537        let conn = Connection::open(db_path).unwrap();
27538        conn.execute_batch(
27539            "PRAGMA journal_mode=WAL;
27540             PRAGMA wal_autocheckpoint=0;
27541             CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
27542             INSERT INTO wal_lock_probe DEFAULT VALUES;
27543             PRAGMA locking_mode=EXCLUSIVE;
27544             BEGIN EXCLUSIVE;",
27545        )
27546        .unwrap();
27547        assert!(substrate::wal_sidecar_path(db_path).exists());
27548        conn
27549    }
27550
27551    #[test]
27552    fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
27553        let dir = setup_graph_index();
27554        let db_path = dir.path().join(".tsift/index.db");
27555        let _lock = hold_wal_database_lock(&db_path);
27556
27557        let err = cmd_index(
27558            dir.path(),
27559            false,
27560            false,
27561            false,
27562            false,
27563            false,
27564            false,
27565            None,
27566            false,
27567            false,
27568            false,
27569            false,
27570            false,
27571            false,
27572        )
27573        .unwrap_err();
27574
27575        let msg = err.to_string();
27576        assert!(msg.contains("indexing"));
27577        assert!(msg.contains("lock diagnostics:"));
27578        assert!(msg.contains("lock: absent"));
27579        assert!(msg.contains("wal: present") || msg.contains("shm: present"));
27580        assert!(msg.contains("wedged writer holding live WAL sidecars"));
27581        assert!(msg.contains("snapshot fallback"));
27582    }
27583
27584    #[test]
27585    fn search_cmd_succeeds_while_writer_lock_is_held() {
27586        let dir = setup_graph_index();
27587        let db_path = dir.path().join(".tsift/index.db");
27588        let _lock = hold_write_lock(&db_path);
27589
27590        let result = cmd_search(
27591            "main".to_string(),
27592            Some(dir.path().to_path_buf()),
27593            5,
27594            Some("lexical".to_string()),
27595            None,
27596            false,
27597            false,
27598            false,
27599            0,
27600            true,
27601            false,
27602            false,
27603            false,
27604            false,
27605            false,
27606            false,
27607        );
27608
27609        assert!(result.is_ok());
27610    }
27611
27612    #[test]
27613    fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
27614        let dir = setup_graph_index();
27615        let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
27616
27617        let result = cmd_search(
27618            "main".to_string(),
27619            Some(dir.path().to_path_buf()),
27620            5,
27621            Some("lexical".to_string()),
27622            None,
27623            false,
27624            false,
27625            false,
27626            0,
27627            true,
27628            false,
27629            false,
27630            false,
27631            false,
27632            false,
27633            false,
27634        );
27635
27636        assert!(result.is_ok());
27637    }
27638
27639    #[test]
27640    fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
27641        let dir = setup_graph_index();
27642        let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
27643
27644        let result = cmd_search(
27645            "main".to_string(),
27646            Some(dir.path().to_path_buf()),
27647            5,
27648            Some("lexical".to_string()),
27649            None,
27650            false,
27651            false,
27652            false,
27653            0,
27654            true,
27655            false,
27656            false,
27657            false,
27658            false,
27659            false,
27660            false,
27661        );
27662
27663        assert!(result.is_ok());
27664    }
27665
27666    #[test]
27667    fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
27668        let dir = setup_graph_index();
27669        std::thread::sleep(std::time::Duration::from_millis(50));
27670        std::fs::write(
27671            dir.path().join("main.rs"),
27672            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27673        )
27674        .unwrap();
27675
27676        let err = cmd_search(
27677            "helper".to_string(),
27678            Some(dir.path().to_path_buf()),
27679            5,
27680            Some("lexical".to_string()),
27681            None,
27682            false,
27683            false,
27684            false,
27685            0,
27686            false,
27687            false,
27688            false,
27689            false,
27690            false,
27691            false,
27692            false,
27693        )
27694        .unwrap_err();
27695
27696        assert!(err.to_string().contains("search aborted"));
27697        assert!(err.to_string().contains("index is stale"));
27698        assert!(err.to_string().contains("--no-autoindex"));
27699    }
27700
27701    #[test]
27702    fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
27703        let dir = setup_graph_index();
27704        std::thread::sleep(std::time::Duration::from_millis(50));
27705        std::fs::write(
27706            dir.path().join("main.rs"),
27707            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27708        )
27709        .unwrap();
27710        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27711
27712        let err = cmd_search(
27713            "helper".to_string(),
27714            Some(dir.path().to_path_buf()),
27715            5,
27716            Some("lexical".to_string()),
27717            None,
27718            false,
27719            false,
27720            false,
27721            0,
27722            false,
27723            false,
27724            false,
27725            false,
27726            false,
27727            false,
27728            false,
27729        )
27730        .unwrap_err();
27731
27732        assert!(err.to_string().contains("search aborted"));
27733        assert!(err.to_string().contains("index is stale"));
27734        assert!(!err.to_string().contains("database is locked"));
27735    }
27736
27737    #[test]
27738    fn search_cmd_autoindexes_stale_index_by_default() {
27739        let dir = setup_graph_index();
27740        std::thread::sleep(std::time::Duration::from_millis(50));
27741        std::fs::write(
27742            dir.path().join("main.rs"),
27743            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27744        )
27745        .unwrap();
27746
27747        let result = cmd_search(
27748            "helper".to_string(),
27749            Some(dir.path().to_path_buf()),
27750            5,
27751            Some("lexical".to_string()),
27752            None,
27753            false,
27754            false,
27755            true,
27756            0,
27757            false,
27758            false,
27759            false,
27760            false,
27761            false,
27762            false,
27763            false,
27764        );
27765
27766        assert!(result.is_ok());
27767
27768        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27769        let summary = db.compute_changes(dir.path()).unwrap();
27770        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27771    }
27772
27773    #[test]
27774    fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
27775        let dir = setup_graph_index();
27776        std::thread::sleep(std::time::Duration::from_millis(50));
27777        std::fs::write(
27778            dir.path().join("main.rs"),
27779            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27780        )
27781        .unwrap();
27782        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27783
27784        let result = cmd_search(
27785            "helper".to_string(),
27786            Some(dir.path().to_path_buf()),
27787            5,
27788            Some("lexical".to_string()),
27789            None,
27790            false,
27791            false,
27792            true,
27793            0,
27794            false,
27795            false,
27796            false,
27797            false,
27798            false,
27799            false,
27800            false,
27801        );
27802
27803        assert!(result.is_ok());
27804
27805        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27806        let summary = db.compute_changes(dir.path()).unwrap();
27807        assert_eq!(summary.modified, 1);
27808    }
27809
27810    #[test]
27811    fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
27812        let dir = setup_graph_index();
27813        std::thread::sleep(std::time::Duration::from_millis(50));
27814        std::fs::write(
27815            dir.path().join("main.rs"),
27816            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27817        )
27818        .unwrap();
27819        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27820
27821        let err = cmd_search(
27822            "helper".to_string(),
27823            Some(dir.path().to_path_buf()),
27824            5,
27825            Some("lexical".to_string()),
27826            None,
27827            false,
27828            false,
27829            true,
27830            0,
27831            false,
27832            false,
27833            false,
27834            false,
27835            false,
27836            false,
27837            false,
27838        )
27839        .unwrap_err();
27840
27841        let msg = err.to_string();
27842        assert!(msg.contains("autoindexing index"));
27843        assert!(msg.contains("lock diagnostics:"));
27844        assert!(msg.contains("journal: present"));
27845        assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
27846    }
27847
27848    #[test]
27849    fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
27850        let dir = setup_graph_index();
27851        let nested = dir.path().join("src/nested");
27852        std::fs::create_dir_all(&nested).unwrap();
27853
27854        let result = cmd_search(
27855            "helper".to_string(),
27856            Some(nested.clone()),
27857            5,
27858            Some("lexical".to_string()),
27859            None,
27860            false,
27861            false,
27862            true,
27863            0,
27864            false,
27865            false,
27866            false,
27867            false,
27868            false,
27869            false,
27870            false,
27871        );
27872
27873        assert!(result.is_ok());
27874        assert!(!nested.join(".tsift/index.db").exists());
27875    }
27876
27877    #[test]
27878    fn exact_search_returns_literal_matches() {
27879        let dir = tempfile::tempdir().unwrap();
27880        std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
27881
27882        let response = run_exact_search_with_timeout(
27883            std::slice::from_ref(&dir.path().to_path_buf()),
27884            "claudescore-3",
27885            5,
27886            0,
27887        )
27888        .unwrap();
27889
27890        assert_eq!(response.strategy, "exact");
27891        assert_eq!(response.hits.len(), 1);
27892        assert!(response.hits[0].path.ends_with("notes.txt"));
27893        assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
27894        assert!(response.hits[0].snippet.contains("claudescore-3"));
27895    }
27896
27897    #[test]
27898    fn exact_search_skips_stale_index_precheck() {
27899        let dir = setup_graph_index();
27900        std::thread::sleep(std::time::Duration::from_millis(50));
27901        std::fs::write(
27902            dir.path().join("main.rs"),
27903            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
27904        )
27905        .unwrap();
27906
27907        let result = cmd_search(
27908            "println!(\"updated\")".to_string(),
27909            Some(dir.path().to_path_buf()),
27910            5,
27911            Some("exact".to_string()),
27912            None,
27913            false,
27914            false,
27915            false,
27916            0,
27917            false,
27918            false,
27919            false,
27920            false,
27921            false,
27922            false,
27923            false,
27924        );
27925
27926        assert!(result.is_ok());
27927    }
27928
27929    #[test]
27930    fn workspace_exact_search_does_not_require_shared_root_index() {
27931        let dir = setup_workspace();
27932        cmd_index(
27933            dir.path(),
27934            false,
27935            false,
27936            false,
27937            false,
27938            false,
27939            true,
27940            None,
27941            false,
27942            false,
27943            false,
27944            false,
27945            false,
27946            false,
27947        )
27948        .unwrap();
27949
27950        let result = cmd_search(
27951            "alpha_helper".to_string(),
27952            Some(dir.path().to_path_buf()),
27953            5,
27954            Some("exact".to_string()),
27955            None,
27956            false,
27957            false,
27958            false,
27959            0,
27960            false,
27961            false,
27962            false,
27963            false,
27964            false,
27965            false,
27966            false,
27967        );
27968
27969        assert!(result.is_ok());
27970        assert!(!dir.path().join(".tsift/index.db").exists());
27971    }
27972
27973    #[test]
27974    fn identifier_like_query_prefers_exact_search() {
27975        assert!(query_prefers_exact_search("claudescore-3"));
27976        assert!(query_prefers_exact_search("alpha_helper"));
27977        assert!(query_prefers_exact_search("src/main.rs"));
27978        assert!(query_prefers_exact_search("crate::module"));
27979        assert!(!query_prefers_exact_search("authenticate"));
27980        assert!(!query_prefers_exact_search("fn main"));
27981        assert!(!query_prefers_exact_search("."));
27982    }
27983
27984    #[test]
27985    fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
27986        assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
27987        assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
27988        assert_eq!(
27989            resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
27990            "hybrid"
27991        );
27992    }
27993
27994    #[test]
27995    fn workspace_identifier_like_search_auto_uses_exact_backend() {
27996        let dir = setup_workspace();
27997        cmd_index(
27998            dir.path(),
27999            false,
28000            false,
28001            false,
28002            false,
28003            false,
28004            true,
28005            None,
28006            false,
28007            false,
28008            false,
28009            false,
28010            false,
28011            false,
28012        )
28013        .unwrap();
28014
28015        let result = cmd_search(
28016            "alpha_helper".to_string(),
28017            Some(dir.path().to_path_buf()),
28018            5,
28019            None,
28020            None,
28021            false,
28022            false,
28023            false,
28024            0,
28025            false,
28026            false,
28027            false,
28028            false,
28029            false,
28030            false,
28031            false,
28032        );
28033
28034        assert!(result.is_ok());
28035        assert!(!dir.path().join(".tsift/index.db").exists());
28036    }
28037
28038    #[test]
28039    fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
28040        let dir = setup_graph_index();
28041        let nested = dir.path().join("src/nested");
28042        std::fs::create_dir_all(&nested).unwrap();
28043        std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
28044
28045        let result = cmd_index(
28046            &nested, false, false, false, false, false, false, None, false, false, false, false,
28047            false, false,
28048        );
28049
28050        assert!(result.is_ok());
28051        assert!(dir.path().join(".tsift/index.db").exists());
28052        assert!(!nested.join(".tsift/index.db").exists());
28053    }
28054
28055    #[test]
28056    fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
28057        let dir = setup_workspace();
28058        let nested = dir.path().join("docs/nested");
28059        std::fs::create_dir_all(&nested).unwrap();
28060
28061        let result = cmd_index(
28062            &nested, false, false, false, false, false, true, None, false, false, false, false,
28063            false, false,
28064        );
28065
28066        let cfg = config::Config::load(dir.path()).unwrap();
28067
28068        assert!(result.is_ok());
28069        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28070        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28071    }
28072
28073    #[test]
28074    fn status_cmd_autoindexes_missing_workspace_scopes() {
28075        let dir = setup_workspace();
28076        let cfg = config::Config::load(dir.path()).unwrap();
28077        let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
28078        let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
28079        let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
28080        alpha_db.apply_changes(&alpha.source_root).unwrap();
28081
28082        let beta_db_path = cfg.db_path_for(dir.path(), "beta");
28083        assert!(!beta_db_path.exists());
28084
28085        cmd_status(
28086            dir.path(),
28087            StatusCommandOptions {
28088                fix: false,
28089                no_fix: false,
28090                json_output: true,
28091                compact: false,
28092                pretty: false,
28093                terse: false,
28094                schema: false,
28095            },
28096        )
28097        .unwrap();
28098
28099        assert!(beta_db_path.exists());
28100        let report = status::check_status(dir.path()).unwrap();
28101        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28102    }
28103
28104    #[test]
28105    fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
28106        let dir = setup_workspace();
28107        let cfg = config::Config::load(dir.path()).unwrap();
28108
28109        cmd_status(
28110            dir.path(),
28111            StatusCommandOptions {
28112                fix: false,
28113                no_fix: false,
28114                json_output: true,
28115                compact: false,
28116                pretty: false,
28117                terse: false,
28118                schema: false,
28119            },
28120        )
28121        .unwrap();
28122
28123        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28124        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28125        let report = status::check_status(dir.path()).unwrap();
28126        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28127    }
28128
28129    #[test]
28130    fn status_cmd_fix_refreshes_stale_index() {
28131        let dir = setup_graph_index();
28132        std::thread::sleep(std::time::Duration::from_millis(50));
28133        std::fs::write(
28134            dir.path().join("main.rs"),
28135            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28136        )
28137        .unwrap();
28138
28139        let report = status::check_status(dir.path()).unwrap();
28140        assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
28141
28142        cmd_status(
28143            dir.path(),
28144            StatusCommandOptions {
28145                fix: false,
28146                no_fix: false,
28147                json_output: true,
28148                compact: false,
28149                pretty: false,
28150                terse: false,
28151                schema: false,
28152            },
28153        )
28154        .unwrap();
28155
28156        let report = status::check_status(dir.path()).unwrap();
28157        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28158    }
28159
28160    #[test]
28161    fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
28162        let dir = setup_graph_index();
28163        let db_path = dir.path().join(".tsift/index.db");
28164        let _lock = hold_wal_database_lock(&db_path);
28165
28166        cmd_status(
28167            dir.path(),
28168            StatusCommandOptions {
28169                fix: false,
28170                no_fix: false,
28171                json_output: true,
28172                compact: false,
28173                pretty: false,
28174                terse: false,
28175                schema: false,
28176            },
28177        )
28178        .unwrap();
28179
28180        let report = status::check_status(dir.path()).unwrap();
28181        assert!(matches!(
28182            report.index,
28183            status::IndexStatus::Fresh {
28184                recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
28185                ..
28186            }
28187        ));
28188        let locks = status::check_locks(dir.path(), None, None).unwrap();
28189        assert!(matches!(
28190            locks.writer_lock,
28191            status::WriterLockStatus::Absent { .. }
28192        ));
28193        assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
28194        assert!(
28195            locks
28196                .recommended_action
28197                .contains("wedged writer holding live WAL sidecars")
28198        );
28199    }
28200
28201    #[test]
28202    fn locks_report_uses_ancestor_project_root_for_nested_paths() {
28203        let dir = setup_graph_index();
28204        let nested = dir.path().join("src/nested");
28205        std::fs::create_dir_all(&nested).unwrap();
28206
28207        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28208        let report = status::check_locks(&root, Some(&nested), None).unwrap();
28209
28210        assert_eq!(report.source_root, dir.path());
28211        assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
28212    }
28213
28214    #[test]
28215    fn workspace_locks_report_infers_scope_from_nested_path() {
28216        let dir = setup_workspace();
28217        cmd_index(
28218            dir.path(),
28219            false,
28220            false,
28221            false,
28222            false,
28223            false,
28224            true,
28225            None,
28226            false,
28227            false,
28228            false,
28229            false,
28230            false,
28231            false,
28232        )
28233        .unwrap();
28234        let nested = dir.path().join("src/alpha/nested");
28235        std::fs::create_dir_all(&nested).unwrap();
28236
28237        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28238        let report = status::check_locks(&root, Some(&nested), None).unwrap();
28239        let cfg = config::Config::load(dir.path()).unwrap();
28240
28241        assert_eq!(report.label, "submodule `alpha` index");
28242        assert_eq!(report.source_root, dir.path().join("src/alpha"));
28243        assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
28244        assert_eq!(
28245            report.reindex_command,
28246            format!("tsift index --submodule alpha {}", dir.path().display())
28247        );
28248    }
28249
28250    #[test]
28251    fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
28252        let dir = setup_workspace();
28253        cmd_index(
28254            dir.path(),
28255            false,
28256            false,
28257            false,
28258            false,
28259            false,
28260            true,
28261            None,
28262            false,
28263            false,
28264            false,
28265            false,
28266            false,
28267            false,
28268        )
28269        .unwrap();
28270
28271        let alpha = dir.path().join("src/alpha/lib.rs");
28272        std::thread::sleep(std::time::Duration::from_millis(50));
28273        std::fs::write(
28274            &alpha,
28275            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28276        )
28277        .unwrap();
28278
28279        let result = cmd_search(
28280            "alpha_helper".to_string(),
28281            Some(dir.path().to_path_buf()),
28282            5,
28283            Some("lexical".to_string()),
28284            Some("alpha".to_string()),
28285            false,
28286            false,
28287            true,
28288            0,
28289            false,
28290            false,
28291            false,
28292            false,
28293            false,
28294            false,
28295            false,
28296        );
28297
28298        assert!(result.is_ok());
28299
28300        let cfg = config::Config::load(dir.path()).unwrap();
28301        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28302        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28303        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28304    }
28305
28306    #[test]
28307    fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28308        let dir = setup_workspace();
28309        cmd_index(
28310            dir.path(),
28311            false,
28312            false,
28313            false,
28314            false,
28315            false,
28316            true,
28317            None,
28318            false,
28319            false,
28320            false,
28321            false,
28322            false,
28323            false,
28324        )
28325        .unwrap();
28326
28327        let alpha = dir.path().join("src/alpha/lib.rs");
28328        std::thread::sleep(std::time::Duration::from_millis(50));
28329        std::fs::write(
28330            &alpha,
28331            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28332        )
28333        .unwrap();
28334
28335        let cfg = config::Config::load(dir.path()).unwrap();
28336        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28337
28338        let err = cmd_search(
28339            "alpha_helper".to_string(),
28340            Some(dir.path().to_path_buf()),
28341            5,
28342            Some("lexical".to_string()),
28343            Some("alpha".to_string()),
28344            false,
28345            false,
28346            false,
28347            0,
28348            false,
28349            false,
28350            false,
28351            false,
28352            false,
28353            false,
28354            false,
28355        )
28356        .unwrap_err();
28357
28358        assert!(err.to_string().contains("search aborted"));
28359        assert!(err.to_string().contains("submodule `alpha` index"));
28360        assert!(!err.to_string().contains("database is locked"));
28361    }
28362
28363    #[test]
28364    fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
28365        let dir = setup_workspace();
28366        cmd_index(
28367            dir.path(),
28368            false,
28369            false,
28370            false,
28371            false,
28372            false,
28373            true,
28374            None,
28375            false,
28376            false,
28377            false,
28378            false,
28379            false,
28380            false,
28381        )
28382        .unwrap();
28383
28384        let alpha = dir.path().join("src/alpha/lib.rs");
28385        std::thread::sleep(std::time::Duration::from_millis(50));
28386        std::fs::write(
28387            &alpha,
28388            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28389        )
28390        .unwrap();
28391
28392        let result = cmd_search(
28393            "alpha_helper".to_string(),
28394            Some(dir.path().to_path_buf()),
28395            5,
28396            Some("lexical".to_string()),
28397            None,
28398            true,
28399            false,
28400            true,
28401            0,
28402            false,
28403            false,
28404            false,
28405            false,
28406            false,
28407            false,
28408            false,
28409        );
28410
28411        assert!(result.is_ok());
28412
28413        let cfg = config::Config::load(dir.path()).unwrap();
28414        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28415        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28416        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28417    }
28418
28419    #[test]
28420    fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28421        let dir = setup_workspace();
28422        cmd_index(
28423            dir.path(),
28424            false,
28425            false,
28426            false,
28427            false,
28428            false,
28429            true,
28430            None,
28431            false,
28432            false,
28433            false,
28434            false,
28435            false,
28436            false,
28437        )
28438        .unwrap();
28439
28440        let alpha = dir.path().join("src/alpha/lib.rs");
28441        std::thread::sleep(std::time::Duration::from_millis(50));
28442        std::fs::write(
28443            &alpha,
28444            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28445        )
28446        .unwrap();
28447
28448        let cfg = config::Config::load(dir.path()).unwrap();
28449        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28450
28451        let err = cmd_search(
28452            "alpha_helper".to_string(),
28453            Some(dir.path().to_path_buf()),
28454            5,
28455            Some("lexical".to_string()),
28456            None,
28457            true,
28458            false,
28459            false,
28460            30,
28461            false,
28462            false,
28463            false,
28464            false,
28465            false,
28466            false,
28467            false,
28468        )
28469        .unwrap_err();
28470
28471        assert!(err.to_string().contains("stale"));
28472        assert!(err.to_string().contains("submodule `alpha` index"));
28473        assert!(!err.to_string().contains("database is locked"));
28474    }
28475
28476    #[test]
28477    fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
28478        let dir = setup_workspace();
28479        cmd_index(
28480            dir.path(),
28481            false,
28482            false,
28483            false,
28484            false,
28485            false,
28486            true,
28487            None,
28488            false,
28489            false,
28490            false,
28491            false,
28492            false,
28493            false,
28494        )
28495        .unwrap();
28496
28497        let err = cmd_search(
28498            "alpha_helper".to_string(),
28499            Some(dir.path().to_path_buf()),
28500            5,
28501            Some("lexical".to_string()),
28502            None,
28503            false,
28504            false,
28505            true,
28506            0,
28507            false,
28508            false,
28509            false,
28510            false,
28511            false,
28512            false,
28513            false,
28514        )
28515        .unwrap_err();
28516
28517        assert_workspace_search_requires_explicit_target(err);
28518        assert!(!dir.path().join(".tsift/index.db").exists());
28519    }
28520
28521    #[test]
28522    fn workspace_search_cmd_infers_scope_from_nested_path() {
28523        let dir = setup_workspace();
28524        cmd_index(
28525            dir.path(),
28526            false,
28527            false,
28528            false,
28529            false,
28530            false,
28531            true,
28532            None,
28533            false,
28534            false,
28535            false,
28536            false,
28537            false,
28538            false,
28539        )
28540        .unwrap();
28541        let nested = dir.path().join("src/alpha/nested");
28542        std::fs::create_dir_all(&nested).unwrap();
28543
28544        let result = cmd_search(
28545            "alpha_helper".to_string(),
28546            Some(nested),
28547            5,
28548            Some("lexical".to_string()),
28549            None,
28550            false,
28551            false,
28552            false,
28553            0,
28554            false,
28555            false,
28556            false,
28557            false,
28558            false,
28559            false,
28560            false,
28561        );
28562
28563        assert!(result.is_ok());
28564    }
28565
28566    #[test]
28567    fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
28568        let dir = setup_workspace_with_duplicate_leaf_names();
28569        cmd_index(
28570            dir.path(),
28571            false,
28572            false,
28573            false,
28574            false,
28575            false,
28576            true,
28577            None,
28578            false,
28579            false,
28580            false,
28581            false,
28582            false,
28583            false,
28584        )
28585        .unwrap();
28586        let nested = dir.path().join("vendor/foo/nested");
28587        std::fs::create_dir_all(&nested).unwrap();
28588
28589        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28590        let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
28591        let cfg = config::Config::load(dir.path()).unwrap();
28592
28593        assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
28594    }
28595
28596    #[test]
28597    fn graph_cmd_succeeds_while_writer_lock_is_held() {
28598        let dir = setup_graph_index();
28599        let db_path = dir.path().join(".tsift/index.db");
28600        let _lock = hold_write_lock(&db_path);
28601
28602        let result = cmd_graph(
28603            "main",
28604            dir.path(),
28605            false,
28606            false,
28607            None,
28608            20,
28609            false,
28610            true,
28611            false,
28612            false,
28613            false,
28614            false,
28615            false,
28616            TagpathSearchOpts::default(),
28617        );
28618
28619        assert!(result.is_ok());
28620    }
28621
28622    #[test]
28623    fn graph_cmd_autoindexes_stale_index_by_default() {
28624        let dir = setup_graph_index();
28625        std::thread::sleep(std::time::Duration::from_millis(50));
28626        std::fs::write(
28627            dir.path().join("main.rs"),
28628            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28629        )
28630        .unwrap();
28631
28632        let result = cmd_graph(
28633            "helper",
28634            dir.path(),
28635            true,
28636            false,
28637            None,
28638            20,
28639            false,
28640            true,
28641            false,
28642            false,
28643            false,
28644            false,
28645            false,
28646            TagpathSearchOpts::default(),
28647        );
28648
28649        assert!(result.is_ok());
28650        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
28651        let summary = db.compute_changes(dir.path()).unwrap();
28652        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28653    }
28654
28655    #[test]
28656    fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28657        let dir = setup_graph_index();
28658        let db_path = dir.path().join(".tsift/index.db");
28659        let _lock = hold_rollback_journal_lock(&db_path);
28660
28661        let result = cmd_graph(
28662            "main",
28663            dir.path(),
28664            false,
28665            false,
28666            None,
28667            20,
28668            false,
28669            true,
28670            false,
28671            false,
28672            false,
28673            false,
28674            false,
28675            TagpathSearchOpts::default(),
28676        );
28677
28678        assert!(result.is_ok());
28679    }
28680
28681    #[test]
28682    fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
28683        let dir = setup_graph_index();
28684        let nested = dir.path().join("src/nested");
28685        std::fs::create_dir_all(&nested).unwrap();
28686
28687        let result = cmd_graph(
28688            "helper",
28689            &nested,
28690            true,
28691            false,
28692            None,
28693            20,
28694            false,
28695            false,
28696            false,
28697            false,
28698            false,
28699            false,
28700            false,
28701            TagpathSearchOpts::default(),
28702        );
28703
28704        assert!(result.is_ok());
28705    }
28706
28707    #[test]
28708    fn communities_cmd_succeeds_while_writer_lock_is_held() {
28709        let dir = setup_graph_index();
28710        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
28711
28712        let result = cmd_communities(
28713            dir.path(),
28714            None,
28715            1,
28716            10,
28717            false,
28718            false,
28719            false,
28720            false,
28721            false,
28722            false,
28723            TagpathSearchOpts::default(),
28724        );
28725
28726        assert!(result.is_ok());
28727    }
28728
28729    #[test]
28730    fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28731        let dir = setup_graph_index();
28732        let db_path = dir.path().join(".tsift/index.db");
28733        let _lock = hold_rollback_journal_lock(&db_path);
28734
28735        let result = cmd_communities(
28736            dir.path(),
28737            None,
28738            1,
28739            10,
28740            false,
28741            false,
28742            false,
28743            false,
28744            false,
28745            false,
28746            TagpathSearchOpts::default(),
28747        );
28748
28749        assert!(result.is_ok());
28750    }
28751
28752    #[test]
28753    fn lint_finds_entities_from_project_root_index_db() {
28754        let dir = tempfile::tempdir().unwrap();
28755        std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
28756        std::fs::write(
28757            dir.path().join("README.md"),
28758            "alpha_helper should be backticked.\n",
28759        )
28760        .unwrap();
28761        cmd_index(
28762            dir.path(),
28763            false,
28764            false,
28765            false,
28766            false,
28767            false,
28768            false,
28769            None,
28770            false,
28771            false,
28772            false,
28773            false,
28774            false,
28775            false,
28776        )
28777        .unwrap();
28778
28779        let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
28780            .unwrap()
28781            .unwrap();
28782        let entities = lint::collect_entities_from_index_path(&root).unwrap();
28783        let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
28784
28785        assert!(
28786            result
28787                .annotations
28788                .iter()
28789                .any(|ann| ann.text == "alpha_helper")
28790        );
28791    }
28792
28793    // --- search timeout ---
28794
28795    #[test]
28796    fn search_direct_runs_ok() {
28797        let dir = tempfile::tempdir().unwrap();
28798        let search_dir = dir.path().to_path_buf();
28799        let cache_dir = search_dir.join(".tsift/search-cache");
28800        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28801        let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical", None);
28802        assert!(result.is_ok(), "direct search should succeed");
28803        assert!(
28804            cache_dir.exists(),
28805            "search should create the configured cache dir"
28806        );
28807    }
28808
28809    #[test]
28810    fn search_timeout_zero_disables_timeout() {
28811        let dir = tempfile::tempdir().unwrap();
28812        let search_dir = dir.path().to_path_buf();
28813        let cache_dir = search_dir.join(".tsift/search-cache");
28814        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28815        let result =
28816            run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[], None);
28817        assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
28818        assert!(
28819            cache_dir.exists(),
28820            "timeout=0 should keep using the stable search cache dir"
28821        );
28822    }
28823
28824    #[test]
28825    fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
28826        let dir = tempfile::tempdir().unwrap();
28827        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
28828        cmd_index(
28829            dir.path(),
28830            false,
28831            false,
28832            false,
28833            false,
28834            false,
28835            false,
28836            None,
28837            false,
28838            false,
28839            false,
28840            false,
28841            false,
28842            false,
28843        )
28844        .unwrap();
28845        let db_path = dir.path().join(".tsift/index.db");
28846        std::fs::remove_file(&db_path).unwrap();
28847        let search_target = SearchIndexTarget {
28848            label: "index".to_string(),
28849            db_path,
28850            source_root: dir.path().to_path_buf(),
28851            scope_name: None,
28852            reindex_cmd: format!("tsift index {}", dir.path().display()),
28853        };
28854
28855        let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
28856
28857        assert!(message.contains("timed out after 1s"));
28858        assert!(message.contains("index is missing"));
28859        assert!(message.contains("Run `tsift index"));
28860        assert!(!message.contains("search root looks fresh"));
28861    }
28862
28863    #[test]
28864    fn search_worker_output_path_uses_json_suffix() {
28865        let path = next_search_worker_output_path();
28866        assert!(path.extension().is_some_and(|ext| ext == "json"));
28867    }
28868
28869    #[test]
28870    fn fts_search_flag_value_parses_falsy_escape_hatch() {
28871        // #015t Phase 4: FTS5 is the default; only falsy values force legacy.
28872        for falsy in ["0", "false", "FALSE", " no ", "Off"] {
28873            assert!(
28874                fts_flag_value_disabled(falsy),
28875                "{falsy:?} should force the legacy TokenIndex path"
28876            );
28877        }
28878        for keeps_default in ["", "1", "true", "yes", "on", "maybe"] {
28879            assert!(
28880                !fts_flag_value_disabled(keeps_default),
28881                "{keeps_default:?} should keep the FTS5 default"
28882            );
28883        }
28884    }
28885
28886    #[test]
28887    fn run_sift_search_defaults_to_fts_when_index_db_present() {
28888        // #015t Phase 4 cutover: with the flag unset and a root index.db present,
28889        // run_sift_search uses the FTS5 path by default (strategy "fts").
28890        let dir = tempfile::tempdir().unwrap();
28891        let root = dir.path();
28892        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28893        index::IndexDb::open(&root.join(".tsift/index.db"))
28894            .unwrap()
28895            .apply_changes(root)
28896            .unwrap();
28897        let cache_dir = root.join(".tsift/search-cache");
28898
28899        // Guard against an ambient escape-hatch env from a parallel test/shell.
28900        if fts_search_forced_off() {
28901            return;
28902        }
28903        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28904        assert_eq!(response.strategy, "fts");
28905        assert!(response.hits.iter().any(|h| h.path.ends_with("alpha.rs")));
28906    }
28907
28908    #[test]
28909    fn run_sift_search_falls_back_to_lexical_without_index_db() {
28910        // No root index.db (e.g. un-indexed root reaching here directly): the
28911        // legacy TokenIndex/lexical path still serves the query.
28912        let dir = tempfile::tempdir().unwrap();
28913        let root = dir.path();
28914        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28915        let cache_dir = root.join(".tsift/search-cache");
28916
28917        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28918        assert_eq!(response.strategy, "lexical");
28919    }
28920
28921    #[test]
28922    fn run_sift_search_honors_threaded_freshness_verdict() {
28923        // #015t Phase 4b: the caller's freshness verdict overrides the in-engine
28924        // inspect. Some(true) ⇒ FTS without re-walking; Some(false) ⇒ legacy path
28925        // even with a fresh index.db (the degraded-read-only live-results case).
28926        let dir = tempfile::tempdir().unwrap();
28927        let root = dir.path();
28928        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28929        index::IndexDb::open(&root.join(".tsift/index.db"))
28930            .unwrap()
28931            .apply_changes(root)
28932            .unwrap();
28933        let cache_dir = root.join(".tsift/search-cache");
28934
28935        if fts_search_forced_off() {
28936            return;
28937        }
28938        let fresh =
28939            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(true)).unwrap();
28940        assert_eq!(fresh.strategy, "fts");
28941
28942        let stale =
28943            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(false)).unwrap();
28944        assert_eq!(stale.strategy, "lexical");
28945    }
28946
28947    // --- index quiet mode ---
28948
28949    #[test]
28950    fn index_quiet_suppresses_file_list() {
28951        let dir = setup_graph_index();
28952        let result = cmd_index(
28953            dir.path(),
28954            false,
28955            true,
28956            false,
28957            false,
28958            true,
28959            false,
28960            None,
28961            false,
28962            false,
28963            false,
28964            false,
28965            false,
28966            false,
28967        );
28968        assert!(result.is_ok());
28969    }
28970
28971    #[test]
28972    fn index_exit_code_implies_quiet() {
28973        let dir = setup_graph_index();
28974        let result = cmd_index(
28975            dir.path(),
28976            false,
28977            true,
28978            false,
28979            false,
28980            false,
28981            false,
28982            None,
28983            false,
28984            false,
28985            false,
28986            false,
28987            false,
28988            false,
28989        );
28990        assert!(result.is_ok());
28991    }
28992
28993    #[test]
28994    fn index_quiet_json_omits_changes() {
28995        let dir = setup_graph_index();
28996        let result = cmd_index(
28997            dir.path(),
28998            false,
28999            true,
29000            false,
29001            false,
29002            true,
29003            false,
29004            None,
29005            true,
29006            false,
29007            false,
29008            false,
29009            false,
29010            false,
29011        );
29012        assert!(result.is_ok());
29013    }
29014
29015    #[test]
29016    fn cli_workflow_defaults_to_search_topic() {
29017        let cli = parse_cli(["tsift", "workflow"]);
29018        match cli.command {
29019            Some(Commands::Workflow { topic, json }) => {
29020                assert_eq!(topic, "search");
29021                assert!(!json);
29022            }
29023            _ => panic!("expected Workflow command"),
29024        }
29025    }
29026
29027    #[test]
29028    fn search_workflow_recipe_preserves_handles_across_expansions() {
29029        let recipe = workflow::search_workflow_recipe();
29030        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29031        assert_eq!(
29032            step_names,
29033            vec![
29034                "exact-anchor",
29035                "semantic-search",
29036                "explain-symbol",
29037                "summarize-selection",
29038                "digest-expansion"
29039            ]
29040        );
29041        assert!(
29042            recipe
29043                .handle_contract
29044                .iter()
29045                .any(|item| item.contains("originating command"))
29046        );
29047        assert!(
29048            recipe.steps[1]
29049                .preserves
29050                .iter()
29051                .any(|item| item.contains("sfam-*"))
29052        );
29053        assert!(
29054            recipe.steps[2]
29055                .preserves
29056                .iter()
29057                .any(|item| item.contains("ecall-*"))
29058        );
29059        assert!(
29060            recipe.steps[4]
29061                .preserves
29062                .iter()
29063                .any(|item| item.contains("artifact handles"))
29064        );
29065    }
29066
29067    #[test]
29068    fn kg_workflow_recipe_covers_extract_to_evidence() {
29069        let recipe = workflow::kg_workflow_recipe();
29070        assert_eq!(recipe.topic, "kg");
29071        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29072        assert_eq!(
29073            step_names,
29074            vec!["smoke-check", "extract", "status", "refresh", "evidence"]
29075        );
29076        // evidence uses --symbol (not a positional) and has no --budget flag
29077        let evidence = recipe.steps.last().unwrap();
29078        assert!(evidence.command.contains("kg evidence --symbol"));
29079        assert!(!evidence.command.contains("--budget"));
29080        // extract is the write step; reads should not re-extract
29081        assert!(
29082            recipe
29083                .handle_contract
29084                .iter()
29085                .any(|item| item.contains("Extract once"))
29086        );
29087    }
29088
29089    // --- JSON compact vs pretty ---
29090
29091    #[test]
29092    fn to_json_compact_default() {
29093        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29094        let compact = to_json(&val, false, false).unwrap();
29095        assert!(!compact.contains('\n'));
29096        assert!(
29097            compact.contains("\"a\":1")
29098                || compact.contains("\"a\": 1")
29099                || compact.contains("\"a\":")
29100        );
29101    }
29102
29103    #[test]
29104    fn to_json_pretty_indents() {
29105        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29106        let pretty = to_json(&val, true, false).unwrap();
29107        assert!(pretty.contains('\n'));
29108        assert!(pretty.contains("  "));
29109    }
29110
29111    #[test]
29112    fn to_json_compact_is_shorter() {
29113        let val =
29114            serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
29115        let compact = to_json(&val, false, false).unwrap();
29116        let pretty = to_json(&val, true, false).unwrap();
29117        assert!(compact.len() < pretty.len());
29118    }
29119
29120    #[test]
29121    fn terse_renames_keys() {
29122        let val =
29123            serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
29124        let result = to_json(&val, false, true).unwrap();
29125        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29126        assert!(parsed["_s"].is_object());
29127        let d = &parsed["d"];
29128        assert_eq!(d["cf"], "a.rs");
29129        assert_eq!(d["cn"], "main");
29130        assert_eq!(d["csl"], 10);
29131    }
29132
29133    #[test]
29134    fn terse_schema_only_includes_used_keys() {
29135        let val = serde_json::json!({"name": "test", "score": 0.5});
29136        let result = to_json(&val, false, true).unwrap();
29137        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29138        let schema = parsed["_s"].as_object().unwrap();
29139        assert_eq!(schema["n"], "name");
29140        assert_eq!(schema["sc"], "score");
29141        assert!(!schema.contains_key("cf"));
29142    }
29143
29144    #[test]
29145    fn terse_nested_arrays() {
29146        let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
29147        let result = to_json(&val, false, true).unwrap();
29148        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29149        let d = &parsed["d"];
29150        assert_eq!(d["crs"][0]["cn"], "a");
29151        assert_eq!(d["crs"][0]["cf"], "b.rs");
29152    }
29153
29154    #[test]
29155    fn terse_preserves_unknown_keys() {
29156        let val = serde_json::json!({"custom_field": "value", "name": "test"});
29157        let result = to_json(&val, false, true).unwrap();
29158        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29159        let d = &parsed["d"];
29160        assert_eq!(d["custom_field"], "value");
29161        assert_eq!(d["n"], "test");
29162    }
29163
29164    // --- ultra-terse ---
29165
29166    #[test]
29167    fn ultra_terse_strips_properties_from_graph_nodes() {
29168        let val = serde_json::json!({
29169            "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
29170        });
29171        let result = to_json_schema(&val, false, true, true, false).unwrap();
29172        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29173        let node = &parsed["d"]["nodes"][0];
29174        assert_eq!(node["id"], "fn:main");
29175        assert_eq!(node["k"], "fn");
29176        assert_eq!(node["n"], "main");
29177        assert!(node.get("properties").is_none());
29178    }
29179
29180    #[test]
29181    fn ultra_terse_strips_properties_from_graph_edges() {
29182        let val = serde_json::json!({
29183            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
29184        });
29185        let result = to_json_schema(&val, false, true, true, false).unwrap();
29186        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29187        let edge = &parsed["d"]["edges"][0];
29188        assert_eq!(edge["from_id"], "a");
29189        assert_eq!(edge["to_id"], "b");
29190        assert_eq!(edge["k"], "c");
29191        assert!(edge.get("properties").is_none());
29192    }
29193
29194    #[test]
29195    fn ultra_terse_abbreviates_edge_kinds() {
29196        let val = serde_json::json!({
29197            "edges": [
29198                {"from_id": "a", "to_id": "b", "kind": "defines"},
29199                {"from_id": "a", "to_id": "c", "kind": "contains"},
29200                {"from_id": "a", "to_id": "d", "kind": "imports"},
29201                {"from_id": "a", "to_id": "e", "kind": "mentions"},
29202                {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
29203                {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
29204                {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
29205                {"from_id": "a", "to_id": "i", "kind": "uses"},
29206                {"from_id": "a", "to_id": "j", "kind": "parent"},
29207                {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
29208            ]
29209        });
29210        let result = to_json_schema(&val, false, true, true, false).unwrap();
29211        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29212        let edges = &parsed["d"]["edges"].as_array().unwrap();
29213        assert_eq!(edges[0]["k"], "d");
29214        assert_eq!(edges[1]["k"], "ct");
29215        assert_eq!(edges[2]["k"], "i");
29216        assert_eq!(edges[3]["k"], "m");
29217        assert_eq!(edges[4]["k"], "sr");
29218        assert_eq!(edges[5]["k"], "bt");
29219        assert_eq!(edges[6]["k"], "sctx");
29220        assert_eq!(edges[7]["k"], "u");
29221        assert_eq!(edges[8]["k"], "p");
29222        assert_eq!(edges[9]["k"], "unknown_edge");
29223    }
29224
29225    #[test]
29226    fn ultra_terse_strips_provenance_freshness_from_edges() {
29227        let val = serde_json::json!({
29228            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
29229        });
29230        let result = to_json_schema(&val, false, true, true, false).unwrap();
29231        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29232        let edge = &parsed["d"]["edges"][0];
29233        assert!(edge.get("provenance").is_none());
29234        assert!(edge.get("freshness").is_none());
29235        assert_eq!(edge["k"], "c");
29236    }
29237
29238    #[test]
29239    fn ultra_terse_truncates_snippets() {
29240        let long_snippet = "x".repeat(120);
29241        let val = serde_json::json!({"snippet": long_snippet});
29242        let result = to_json_schema(&val, false, true, true, false).unwrap();
29243        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29244        let snipped = parsed["d"]["sn"].as_str().unwrap();
29245        assert_eq!(snipped.len(), 80);
29246        assert!(snipped.ends_with("..."));
29247    }
29248
29249    #[test]
29250    fn ultra_terse_truncates_abbreviated_snippet_key() {
29251        let long_snippet = "y".repeat(100);
29252        let val = serde_json::json!({"snippet": long_snippet});
29253        let result = to_json_schema(&val, false, true, true, false).unwrap();
29254        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29255        let snipped = parsed["d"]["sn"].as_str().unwrap();
29256        assert_eq!(snipped.len(), 80);
29257        assert!(snipped.ends_with("..."));
29258    }
29259
29260    #[test]
29261    fn ultra_terse_compacts_coverage_snapshot() {
29262        let val = serde_json::json!({
29263            "mode": "incremental",
29264            "total_sector_count": 10,
29265            "dirty_sector_count": 2,
29266            "active_rebuild": Some("rebuild-1"),
29267            "completed_dirty_sector_count": 1,
29268            "mounted_sector_count": 8,
29269            "rebuilding_sector_count": 1,
29270            "resumed_sector_count": 3,
29271            "reused_sector_count": 5
29272        });
29273        let result = to_json_schema(&val, false, true, true, false).unwrap();
29274        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29275        let d = &parsed["d"];
29276        assert_eq!(d["mode"], "incremental");
29277        assert_eq!(d["total_sector_count"], 10);
29278        assert_eq!(d["dirty_sector_count"], 2);
29279        assert!(d.get("active_rebuild").is_none());
29280        assert!(d.get("completed_dirty_sector_count").is_none());
29281        assert!(d.get("mounted_sector_count").is_none());
29282        assert!(d.get("rebuilding_sector_count").is_none());
29283        assert!(d.get("resumed_sector_count").is_none());
29284        assert!(d.get("reused_sector_count").is_none());
29285    }
29286
29287    #[test]
29288    fn ultra_terse_short_snippet_unchanged() {
29289        let val = serde_json::json!({"snippet": "short text"});
29290        let result = to_json_schema(&val, false, true, true, false).unwrap();
29291        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29292        assert_eq!(parsed["d"]["sn"], "short text");
29293    }
29294
29295    #[test]
29296    fn ultra_terse_non_graph_object_properties_preserved() {
29297        let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
29298        let result = to_json_schema(&val, false, true, true, false).unwrap();
29299        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29300        assert!(parsed["d"]["config"]["properties"].is_object());
29301    }
29302
29303    // --- schema-then-values ---
29304
29305    #[test]
29306    fn schema_converts_homogeneous_arrays() {
29307        let val = serde_json::json!({"symbols": [
29308            {"name": "foo", "kind": "fn", "line": 10},
29309            {"name": "bar", "kind": "fn", "line": 20}
29310        ]});
29311        let result = to_json_schema(&val, false, false, false, true).unwrap();
29312        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29313        let syms = &parsed["symbols"];
29314        let columns = syms["_c"]
29315            .as_array()
29316            .unwrap()
29317            .iter()
29318            .map(|value| value.as_str().unwrap())
29319            .collect::<Vec<_>>();
29320        let row0 = syms["_r"][0].as_array().unwrap();
29321        let row1 = syms["_r"][1].as_array().unwrap();
29322        let name_index = columns.iter().position(|column| *column == "name").unwrap();
29323        let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
29324        let line_index = columns.iter().position(|column| *column == "line").unwrap();
29325        assert_eq!(row0[name_index], "foo");
29326        assert_eq!(row0[kind_index], "fn");
29327        assert_eq!(row0[line_index], 10);
29328        assert_eq!(row1[name_index], "bar");
29329        assert_eq!(row1[kind_index], "fn");
29330        assert_eq!(row1[line_index], 20);
29331    }
29332
29333    #[test]
29334    fn schema_skips_short_arrays() {
29335        let val = serde_json::json!({"items": [{"name": "only"}]});
29336        let result = to_json_schema(&val, false, false, false, true).unwrap();
29337        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29338        assert!(parsed["items"].is_array());
29339        assert_eq!(parsed["items"][0]["name"], "only");
29340    }
29341
29342    #[test]
29343    fn schema_skips_heterogeneous_arrays() {
29344        let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
29345        let result = to_json_schema(&val, false, false, false, true).unwrap();
29346        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29347        assert!(parsed["items"].is_array());
29348        assert_eq!(parsed["items"][0]["a"], 1);
29349    }
29350
29351    #[test]
29352    fn schema_with_terse_combines() {
29353        let val = serde_json::json!({"callers": [
29354            {"caller_name": "a", "caller_file": "x.rs"},
29355            {"caller_name": "b", "caller_file": "y.rs"}
29356        ]});
29357        let result = to_json_schema(&val, false, true, false, true).unwrap();
29358        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29359        assert!(parsed["_s"].is_object());
29360        let d = &parsed["d"];
29361        let crs = &d["crs"];
29362        assert!(crs["_c"].is_array());
29363        assert!(crs["_r"].is_array());
29364        let columns = crs["_c"]
29365            .as_array()
29366            .unwrap()
29367            .iter()
29368            .map(|value| value.as_str().unwrap())
29369            .collect::<Vec<_>>();
29370        let row = crs["_r"][0].as_array().unwrap();
29371        let name_index = columns.iter().position(|column| *column == "cn").unwrap();
29372        let file_index = columns.iter().position(|column| *column == "cf").unwrap();
29373        assert_eq!(row[name_index], "a");
29374        assert_eq!(row[file_index], "x.rs");
29375    }
29376
29377    #[test]
29378    fn schema_preserves_non_object_arrays() {
29379        let val = serde_json::json!({"tags": ["a", "b", "c"]});
29380        let result = to_json_schema(&val, false, false, false, true).unwrap();
29381        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29382        assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
29383    }
29384
29385    #[test]
29386    fn cli_accepts_global_schema_flag() {
29387        let cli = parse_cli(["tsift", "--schema", "search", "test"]);
29388        assert!(cli.schema);
29389        assert!(matches!(cli.command, Some(Commands::Search { .. })));
29390    }
29391
29392    #[test]
29393    fn cli_accepts_global_envelope_flag() {
29394        let cli = parse_cli([
29395            "tsift",
29396            "--envelope",
29397            "context-pack",
29398            "tasks/software/tsift.md",
29399        ]);
29400        assert!(cli.envelope);
29401        assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
29402    }
29403
29404    #[test]
29405    fn cli_accepts_locks_command() {
29406        let cli = parse_cli(["tsift", "locks"]);
29407        assert!(matches!(cli.command, Some(Commands::Locks { .. })));
29408    }
29409
29410    #[test]
29411    fn cli_parses_memory_budget_guard_command() {
29412        let cli = parse_cli([
29413            "tsift",
29414            "memory",
29415            "budget-guard",
29416            "--file",
29417            "tool.log",
29418            "--budget-tokens",
29419            "1000",
29420            "--json",
29421        ]);
29422        match cli.command {
29423            Some(Commands::Memory {
29424                command:
29425                    crate::cli::MemoryCommand::BudgetGuard {
29426                        file,
29427                        budget_tokens,
29428                        json,
29429                        ..
29430                    },
29431            }) => {
29432                assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
29433                assert_eq!(budget_tokens, 1000);
29434                assert!(json);
29435            }
29436            _ => panic!("expected memory budget-guard command"),
29437        }
29438    }
29439
29440    #[test]
29441    fn cli_parses_memory_capture_agent_doc_closeout_command() {
29442        let cli = parse_cli([
29443            "tsift",
29444            "memory",
29445            "capture-agent-doc-closeout",
29446            ".",
29447            "--session-path",
29448            "tasks/software/tsift.md",
29449            "--prompt-target",
29450            "do [#tsiftmemhooks]",
29451            "--response-summary",
29452            "wired closeout capture",
29453            "--commit-hash",
29454            "abc123",
29455            "--session-check-status",
29456            "clean",
29457            "--json",
29458        ]);
29459        match cli.command {
29460            Some(Commands::Memory {
29461                command:
29462                    crate::cli::MemoryCommand::CaptureAgentDocCloseout {
29463                        path,
29464                        session_path,
29465                        prompt_target,
29466                        response_summary,
29467                        commit_hash,
29468                        session_check_status,
29469                        json,
29470                    },
29471            }) => {
29472                assert_eq!(path, std::path::PathBuf::from("."));
29473                assert_eq!(
29474                    session_path,
29475                    std::path::PathBuf::from("tasks/software/tsift.md")
29476                );
29477                assert_eq!(prompt_target, "do [#tsiftmemhooks]");
29478                assert_eq!(response_summary, "wired closeout capture");
29479                assert_eq!(commit_hash.as_deref(), Some("abc123"));
29480                assert_eq!(session_check_status, "clean");
29481                assert!(json);
29482            }
29483            _ => panic!("expected memory capture-agent-doc-closeout command"),
29484        }
29485    }
29486
29487    #[test]
29488    fn cli_parses_memory_project_graph_read_policy() {
29489        let cli = parse_cli([
29490            "tsift",
29491            "memory",
29492            "project-graph",
29493            ".",
29494            "--read-policy",
29495            "query-relevant",
29496            "--query",
29497            "semantic memory",
29498            "--limit",
29499            "7",
29500            "--json",
29501        ]);
29502        match cli.command {
29503            Some(Commands::Memory {
29504                command:
29505                    crate::cli::MemoryCommand::ProjectGraph {
29506                        read_policy,
29507                        query,
29508                        limit,
29509                        json,
29510                        ..
29511                    },
29512            }) => {
29513                assert_eq!(
29514                    read_policy,
29515                    crate::cli::MemoryProjectReadPolicy::QueryRelevant
29516                );
29517                assert_eq!(query.as_deref(), Some("semantic memory"));
29518                assert_eq!(limit, 7);
29519                assert!(json);
29520            }
29521            _ => panic!("expected memory project-graph command"),
29522        }
29523    }
29524
29525    #[test]
29526    fn cli_locks_accepts_scope_flag() {
29527        let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
29528        match cli.command {
29529            Some(Commands::Locks { scope, .. }) => {
29530                assert_eq!(scope.as_deref(), Some("alpha"));
29531            }
29532            _ => panic!("expected Locks command"),
29533        }
29534    }
29535
29536    #[test]
29537    fn cli_search_accepts_autoindex_flag() {
29538        let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
29539        match cli.command {
29540            Some(Commands::Search {
29541                autoindex,
29542                no_autoindex,
29543                ..
29544            }) => {
29545                assert!(autoindex);
29546                assert!(!no_autoindex);
29547            }
29548            _ => panic!("expected Search command"),
29549        }
29550    }
29551
29552    #[test]
29553    fn cli_search_accepts_exact_flag() {
29554        let cli = parse_cli(["tsift", "search", "test", "--exact"]);
29555        match cli.command {
29556            Some(Commands::Search {
29557                exact, strategy, ..
29558            }) => {
29559                assert!(exact);
29560                assert!(strategy.is_none());
29561            }
29562            _ => panic!("expected Search command"),
29563        }
29564    }
29565
29566    #[test]
29567    fn cli_parses_diff_digest_command() {
29568        let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
29569        match cli.command {
29570            Some(Commands::DiffDigest {
29571                json,
29572                path,
29573                cached,
29574                revision,
29575                max_parsed_files,
29576            }) => {
29577                assert!(json);
29578                assert_eq!(path, PathBuf::from("."));
29579                assert!(!cached);
29580                assert!(revision.is_none());
29581                assert_eq!(max_parsed_files, 25);
29582            }
29583            _ => panic!("expected DiffDigest command"),
29584        }
29585    }
29586
29587    #[test]
29588    fn cli_rejects_conflicting_diff_digest_modes() {
29589        match try_parse_cli([
29590            "tsift",
29591            "diff-digest",
29592            "--cached",
29593            "--revision",
29594            "HEAD",
29595            ".",
29596        ]) {
29597            Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
29598            Err(err) => {
29599                assert!(err.to_string().contains("--cached"));
29600                assert!(err.to_string().contains("--revision"));
29601            }
29602        }
29603    }
29604
29605    #[test]
29606    fn cli_parses_test_digest_command() {
29607        let cli = parse_cli([
29608            "tsift",
29609            "test-digest",
29610            "--path",
29611            ".",
29612            "--input",
29613            "target/test.log",
29614            "--runner",
29615            "cargo",
29616            "--json",
29617        ]);
29618        match cli.command {
29619            Some(Commands::TestDigest {
29620                json,
29621                path,
29622                input,
29623                runner,
29624            }) => {
29625                assert!(json);
29626                assert_eq!(path, PathBuf::from("."));
29627                assert_eq!(input, Some(PathBuf::from("target/test.log")));
29628                assert_eq!(runner.as_deref(), Some("cargo"));
29629            }
29630            _ => panic!("expected TestDigest command"),
29631        }
29632    }
29633
29634    #[test]
29635    fn cli_parses_log_digest_command() {
29636        let cli = parse_cli([
29637            "tsift",
29638            "log-digest",
29639            "--path",
29640            ".",
29641            "--input",
29642            "target/build.log",
29643            "--json",
29644        ]);
29645        match cli.command {
29646            Some(Commands::LogDigest {
29647                json,
29648                path,
29649                input,
29650                fixture,
29651                fail_under,
29652            }) => {
29653                assert!(json);
29654                assert_eq!(path, PathBuf::from("."));
29655                assert_eq!(input, Some(PathBuf::from("target/build.log")));
29656                assert!(fixture.is_none());
29657                assert!(!fail_under);
29658            }
29659            _ => panic!("expected LogDigest command"),
29660        }
29661    }
29662
29663    #[test]
29664    fn cli_parses_metric_digest_command() {
29665        let cli = parse_cli([
29666            "tsift",
29667            "metric-digest",
29668            "--input",
29669            "target/runs.json",
29670            "--baseline",
29671            "target/prior.json",
29672            "--metric",
29673            "session_mae",
29674            "--lower-is-better",
29675            "session_mae",
29676            "--history",
29677            "4",
29678            "--top",
29679            "2",
29680            "--json",
29681        ]);
29682        match cli.command {
29683            Some(Commands::MetricDigest {
29684                input,
29685                baseline,
29686                metrics,
29687                lower_is_better,
29688                history,
29689                top,
29690                json,
29691                ..
29692            }) => {
29693                assert!(json);
29694                assert_eq!(input, Some(PathBuf::from("target/runs.json")));
29695                assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
29696                assert_eq!(metrics, vec!["session_mae"]);
29697                assert_eq!(lower_is_better, vec!["session_mae"]);
29698                assert_eq!(history, 4);
29699                assert_eq!(top, 2);
29700            }
29701            _ => panic!("expected MetricDigest command"),
29702        }
29703    }
29704
29705    #[test]
29706    fn cli_parses_dci_benchmark_command() {
29707        let cli = parse_cli([
29708            "tsift",
29709            "dci-benchmark",
29710            "--fixture",
29711            "fixtures/dci-search-benchmark.json",
29712            "--json",
29713        ]);
29714        match cli.command {
29715            Some(Commands::DciBenchmark { fixture, json }) => {
29716                assert!(json);
29717                assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
29718            }
29719            _ => panic!("expected DciBenchmark command"),
29720        }
29721    }
29722
29723    #[test]
29724    fn cli_parses_session_digest_command() {
29725        let cli = parse_cli([
29726            "tsift",
29727            "session-digest",
29728            "--path",
29729            ".",
29730            "--input",
29731            "target/session.md",
29732            "--source",
29733            "markdown",
29734            "--json",
29735        ]);
29736        match cli.command {
29737            Some(Commands::SessionDigest {
29738                json,
29739                path,
29740                input,
29741                source,
29742            }) => {
29743                assert!(json);
29744                assert_eq!(path, PathBuf::from("."));
29745                assert_eq!(input, Some(PathBuf::from("target/session.md")));
29746                assert_eq!(source.as_deref(), Some("markdown"));
29747            }
29748            _ => panic!("expected SessionDigest command"),
29749        }
29750    }
29751
29752    #[test]
29753    fn cli_parses_session_cost_command() {
29754        let cli = parse_cli([
29755            "tsift",
29756            "session-cost",
29757            "--input",
29758            "target/session.jsonl",
29759            "--source",
29760            "codex-jsonl",
29761            "--json",
29762        ]);
29763        match cli.command {
29764            Some(Commands::SessionCost {
29765                json,
29766                input,
29767                fixture,
29768                fail_under,
29769                source,
29770            }) => {
29771                assert!(json);
29772                assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
29773                assert_eq!(fixture, None);
29774                assert!(!fail_under);
29775                assert_eq!(source.as_deref(), Some("codex-jsonl"));
29776            }
29777            _ => panic!("expected SessionCost command"),
29778        }
29779
29780        let cli = parse_cli([
29781            "tsift",
29782            "session-cost",
29783            "--fixture",
29784            "fixtures/real-session-prompt-cache-effectiveness.json",
29785            "--fail-under",
29786            "--json",
29787        ]);
29788        match cli.command {
29789            Some(Commands::SessionCost {
29790                json,
29791                input,
29792                fixture,
29793                fail_under,
29794                source,
29795            }) => {
29796                assert!(json);
29797                assert_eq!(input, None);
29798                assert_eq!(
29799                    fixture,
29800                    Some(PathBuf::from(
29801                        "fixtures/real-session-prompt-cache-effectiveness.json"
29802                    ))
29803                );
29804                assert!(fail_under);
29805                assert_eq!(source, None);
29806            }
29807            _ => panic!("expected SessionCost command"),
29808        }
29809    }
29810
29811    #[test]
29812    fn cli_parses_session_review_command() {
29813        let cli = parse_cli([
29814            "tsift",
29815            "session-review",
29816            "tasks/software/tsift.md",
29817            "--next-context",
29818            "--json",
29819        ]);
29820        match cli.command {
29821            Some(Commands::SessionReview {
29822                json,
29823                next_context,
29824                path,
29825                ..
29826            }) => {
29827                assert!(json);
29828                assert!(next_context);
29829                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29830            }
29831            _ => panic!("expected SessionReview command"),
29832        }
29833    }
29834
29835    #[test]
29836    fn cli_search_accepts_budget_flags() {
29837        let cli = parse_cli([
29838            "tsift",
29839            "search",
29840            "alpha_helper",
29841            "--max-items",
29842            "3",
29843            "--max-bytes",
29844            "96",
29845        ]);
29846        match cli.command {
29847            Some(Commands::Search {
29848                max_items,
29849                max_bytes,
29850                ..
29851            }) => {
29852                assert_eq!(max_items, Some(3));
29853                assert_eq!(max_bytes, Some(96));
29854            }
29855            _ => panic!("expected Search command"),
29856        }
29857    }
29858
29859    #[test]
29860    fn cli_search_accepts_budget_preset() {
29861        let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
29862        match cli.command {
29863            Some(Commands::Search { budget, .. }) => {
29864                assert_eq!(budget, Some(ResponseBudgetPreset::Small));
29865            }
29866            _ => panic!("expected Search command"),
29867        }
29868    }
29869
29870    #[test]
29871    fn cli_search_accepts_ast_facet_filters() {
29872        let cli = parse_cli([
29873            "tsift",
29874            "search",
29875            "setup",
29876            "--lang",
29877            "markdown",
29878            "--kind",
29879            "list_item",
29880            "--node-kind",
29881            "list_item",
29882            "--section",
29883            "Install",
29884            "--parent",
29885            "Run setup.",
29886            "--child",
29887            "Confirm setup.",
29888            "--fence-language",
29889            "rust",
29890            "--list-depth",
29891            "1",
29892            "--heading-level",
29893            "2",
29894        ]);
29895        match cli.command {
29896            Some(Commands::Search {
29897                lang,
29898                kind,
29899                node_kind,
29900                section,
29901                parent,
29902                child,
29903                fence_language,
29904                list_depth,
29905                heading_level,
29906                ..
29907            }) => {
29908                assert_eq!(lang, vec!["markdown"]);
29909                assert_eq!(kind, vec!["list_item"]);
29910                assert_eq!(node_kind, vec!["list_item"]);
29911                assert_eq!(section, vec!["Install"]);
29912                assert_eq!(parent, vec!["Run setup."]);
29913                assert_eq!(child, vec!["Confirm setup."]);
29914                assert_eq!(fence_language, vec!["rust"]);
29915                assert_eq!(list_depth, vec![1]);
29916                assert_eq!(heading_level, vec![2]);
29917            }
29918            _ => panic!("expected Search command"),
29919        }
29920    }
29921
29922    #[test]
29923    fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
29924        let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
29925        assert_eq!(small.preview_items(), 3);
29926        assert_eq!(small.preview_bytes(), 120);
29927        assert_eq!(small.follow_up_items(), 4);
29928
29929        let overridden =
29930            ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
29931        assert_eq!(overridden.preview_items(), 7);
29932        assert_eq!(overridden.preview_bytes(), 120);
29933        assert_eq!(overridden.follow_up_items(), 7);
29934
29935        let envelope_default = ResponseBudget::from_cli(None, None, None, true);
29936        assert!(envelope_default.is_active());
29937    }
29938
29939    #[test]
29940    fn cli_explain_accepts_budget_flags() {
29941        let cli = parse_cli([
29942            "tsift",
29943            "explain",
29944            "alpha_helper",
29945            "--max-items",
29946            "2",
29947            "--max-bytes",
29948            "80",
29949        ]);
29950        match cli.command {
29951            Some(Commands::Explain {
29952                max_items,
29953                max_bytes,
29954                ..
29955            }) => {
29956                assert_eq!(max_items, Some(2));
29957                assert_eq!(max_bytes, Some(80));
29958            }
29959            _ => panic!("expected Explain command"),
29960        }
29961    }
29962
29963    #[test]
29964    fn cli_session_review_accepts_budget_flags() {
29965        let cli = parse_cli([
29966            "tsift",
29967            "session-review",
29968            "tasks/software/tsift.md",
29969            "--max-items",
29970            "4",
29971            "--max-bytes",
29972            "120",
29973        ]);
29974        match cli.command {
29975            Some(Commands::SessionReview {
29976                max_items,
29977                max_bytes,
29978                ..
29979            }) => {
29980                assert_eq!(max_items, Some(4));
29981                assert_eq!(max_bytes, Some(120));
29982            }
29983            _ => panic!("expected SessionReview command"),
29984        }
29985    }
29986
29987    #[test]
29988    fn cli_parses_context_pack_command() {
29989        let cli = parse_cli([
29990            "tsift",
29991            "context-pack",
29992            "tasks/software/tsift.md",
29993            "--test-input",
29994            "target/test.log",
29995            "--runner",
29996            "cargo",
29997            "--log-input",
29998            "target/build.log",
29999            "--max-items",
30000            "3",
30001            "--max-bytes",
30002            "96",
30003            "--json",
30004        ]);
30005        match cli.command {
30006            Some(Commands::ContextPack {
30007                path,
30008                test_input,
30009                runner,
30010                log_input,
30011                json,
30012                max_items,
30013                max_bytes,
30014                budget,
30015                convex_snapshot,
30016            }) => {
30017                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
30018                assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
30019                assert_eq!(runner.as_deref(), Some("cargo"));
30020                assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
30021                assert!(json);
30022                assert_eq!(max_items, Some(3));
30023                assert_eq!(max_bytes, Some(96));
30024                assert!(budget.is_none());
30025                assert!(convex_snapshot.is_none());
30026            }
30027            _ => panic!("expected ContextPack command"),
30028        }
30029    }
30030
30031    #[test]
30032    fn cli_parses_token_savings_command() {
30033        let cli = parse_cli([
30034            "tsift",
30035            "token-savings",
30036            "--fixture",
30037            "fixtures/tsift-token-savings.json",
30038            "--fail-under",
30039            "--json",
30040        ]);
30041        match cli.command {
30042            Some(Commands::TokenSavings {
30043                fixture,
30044                fail_under,
30045                json,
30046            }) => {
30047                assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
30048                assert!(fail_under);
30049                assert!(json);
30050            }
30051            _ => panic!("expected TokenSavings command"),
30052        }
30053    }
30054
30055    #[test]
30056    fn token_savings_report_records_fixture_thresholds() {
30057        let raw_symbols = [
30058            "validate_user",
30059            "validateUser",
30060            "ValidateUser",
30061            "validate-user",
30062            "VALIDATE_USER",
30063            "Validate_User",
30064            "raw_symbol",
30065            "rawSymbol",
30066            "RawSymbol",
30067            "raw-symbol",
30068            "RAW_SYMBOL",
30069            "Raw_Symbol",
30070        ]
30071        .iter()
30072        .enumerate()
30073        .map(|(idx, identifier)| TokenSavingsRawSymbol {
30074            identifier: (*identifier).to_string(),
30075            file: format!("src/example_{idx}.rs"),
30076            line: (idx + 1) as u64,
30077            context: "function".to_string(),
30078        })
30079        .collect();
30080        let fixture = TokenSavingsFixture {
30081            schema_version: 1,
30082            description: "fixture".to_string(),
30083            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30084            cases: vec![TokenSavingsFixtureCase {
30085                name: "search-preview".to_string(),
30086                surface: "search".to_string(),
30087                minimum_savings_percent: 40.0,
30088                raw_symbols,
30089                tagpath_families: vec![
30090                    TokenSavingsFamily {
30091                        canonical: "validate_user".to_string(),
30092                        count: 6,
30093                        aliases: BTreeMap::new(),
30094                    },
30095                    TokenSavingsFamily {
30096                        canonical: "raw_symbol".to_string(),
30097                        count: 6,
30098                        aliases: BTreeMap::new(),
30099                    },
30100                ],
30101                context_pack_inputs: None,
30102                session_review_inputs: None,
30103                source_read_inputs: None,
30104                markdown_projection_inputs: None,
30105            }],
30106        };
30107
30108        let report = build_token_savings_report(&fixture).unwrap();
30109
30110        assert!(report.pass);
30111        assert_eq!(report.cases[0].raw_symbol_count, 12);
30112        assert_eq!(report.cases[0].family_count, 2);
30113        assert_eq!(report.cases[0].status, "pass");
30114        assert!(report.cases[0].byte_delta > 0);
30115        assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
30116        assert!(report.cases[0].savings_percent >= 40.0);
30117    }
30118
30119    #[test]
30120    fn token_savings_source_read_inputs_preserve_required_anchors() {
30121        let fixture = TokenSavingsFixture {
30122            schema_version: 1,
30123            description: "fixture".to_string(),
30124            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30125            cases: vec![TokenSavingsFixtureCase {
30126                name: "source-read".to_string(),
30127                surface: "source-read".to_string(),
30128                minimum_savings_percent: 40.0,
30129                raw_symbols: Vec::new(),
30130                tagpath_families: Vec::new(),
30131                context_pack_inputs: None,
30132                session_review_inputs: None,
30133                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30134                    reads: vec![TokenSavingsSourceReadInput {
30135                        command: "sed -n '40,160p' src/main.rs".to_string(),
30136                        file: "src/main.rs".to_string(),
30137                        raw_start: 40,
30138                        raw_lines: 121,
30139                        raw_excerpt: "line 40\n".repeat(121),
30140                        envelope_start: 40,
30141                        envelope_lines: 121,
30142                        required_line_anchors: vec![40, 120, 160],
30143                    }],
30144                }),
30145                markdown_projection_inputs: None,
30146            }],
30147        };
30148
30149        let report = build_token_savings_report(&fixture).unwrap();
30150
30151        assert!(report.pass);
30152        assert_eq!(report.cases[0].surface, "source-read");
30153        assert!(report.cases[0].savings_percent >= 40.0);
30154    }
30155
30156    #[test]
30157    fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
30158        let fixture = TokenSavingsFixture {
30159            schema_version: 1,
30160            description: "fixture".to_string(),
30161            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30162            cases: vec![TokenSavingsFixtureCase {
30163                name: "source-read".to_string(),
30164                surface: "source-read".to_string(),
30165                minimum_savings_percent: 40.0,
30166                raw_symbols: Vec::new(),
30167                tagpath_families: Vec::new(),
30168                context_pack_inputs: None,
30169                session_review_inputs: None,
30170                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30171                    reads: vec![TokenSavingsSourceReadInput {
30172                        command: "cat src/main.rs".to_string(),
30173                        file: "src/main.rs".to_string(),
30174                        raw_start: 1,
30175                        raw_lines: 200,
30176                        raw_excerpt: "line\n".repeat(200),
30177                        envelope_start: 1,
30178                        envelope_lines: 80,
30179                        required_line_anchors: vec![120],
30180                    }],
30181                }),
30182                markdown_projection_inputs: None,
30183            }],
30184        };
30185
30186        let err = match build_token_savings_report(&fixture) {
30187            Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
30188            Err(err) => err,
30189        };
30190
30191        assert!(err.to_string().contains("hides required line anchor 120"));
30192    }
30193
30194    #[test]
30195    fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
30196        let fixture = TokenSavingsFixture {
30197            schema_version: 1,
30198            description: "fixture".to_string(),
30199            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30200            cases: vec![TokenSavingsFixtureCase {
30201                name: "markdown-projection".to_string(),
30202                surface: "context-pack".to_string(),
30203                minimum_savings_percent: 40.0,
30204                raw_symbols: Vec::new(),
30205                tagpath_families: Vec::new(),
30206                context_pack_inputs: None,
30207                session_review_inputs: None,
30208                source_read_inputs: None,
30209                markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
30210                    documents: vec![TokenSavingsMarkdownProjectionInput {
30211                        command: "context-pack markdown body".to_string(),
30212                        file: "tasks/software/tsift.md".to_string(),
30213                        raw_markdown: "# Heading\n\n".repeat(120),
30214                        outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
30215                        selected_nodes: vec!["mdast-selected".to_string()],
30216                        expand:
30217                            "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
30218                                .to_string(),
30219                    }],
30220                }),
30221            }],
30222        };
30223
30224        let report = build_token_savings_report(&fixture).unwrap();
30225
30226        assert!(report.pass);
30227        assert_eq!(report.cases[0].surface, "context-pack");
30228        assert!(report.cases[0].savings_percent >= 40.0);
30229    }
30230
30231    #[test]
30232    fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
30233        let mut content = String::from("# Cache Root\n\n");
30234        for idx in 0..96 {
30235            content.push_str(&format!(
30236                "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
30237            ));
30238        }
30239
30240        let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30241        assert!(!first.cache_hit);
30242        assert!(first.nodes.len() > 200);
30243
30244        let sections = markdown_section_spans(&content).unwrap();
30245        let list_items = markdown_block_spans(&content, "list_item").unwrap();
30246        let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
30247        let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30248
30249        assert!(second.cache_hit);
30250        assert_eq!(second.nodes.len(), first.nodes.len());
30251        assert_eq!(sections.len(), 97);
30252        assert_eq!(list_items.len(), 96);
30253        assert_eq!(code_blocks.len(), 96);
30254        let first_code = first
30255            .nodes
30256            .iter()
30257            .find(|node| node.kind == "code_block")
30258            .expect("expected a Markdown code block");
30259        let first_code_node = markdown_ast_node(
30260            Path::new("/repo"),
30261            "semantic-edit",
30262            first_code,
30263            content.as_bytes(),
30264            &first.nodes,
30265            8,
30266        );
30267        assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
30268        assert_eq!(
30269            first_code_node.metadata.embedded_symbols[0].name,
30270            "sample_0"
30271        );
30272        assert_eq!(
30273            first_code_node.metadata.embedded_symbols[0].language,
30274            "rust"
30275        );
30276    }
30277
30278    #[test]
30279    fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
30280        let response = empty_search_response(Path::new("/repo"), "lexical");
30281        let symbol_hits = vec![index::SymbolHit {
30282            name: "alpha_helper_with_a_long_name".to_string(),
30283            kind: "function".to_string(),
30284            language: "rust".to_string(),
30285            file: "/repo/src/lib.rs".to_string(),
30286            line: 12,
30287            end_line: None,
30288            node_kind: None,
30289            start_byte: None,
30290            end_byte: None,
30291            body_start_byte: None,
30292            body_end_byte: None,
30293            tags: None,
30294            score: 0.98,
30295            match_type: "exact_name".to_string(),
30296            tagpath_handle: None,
30297        }];
30298
30299        let report = build_relative_search_budget_report(
30300            "alpha_helper_with_a_long_name",
30301            "lexical",
30302            Path::new("/repo"),
30303            &response,
30304            &symbol_hits,
30305            ResponseBudget::new(Some(1), Some(12)),
30306            &SearchFacetFilters::default(),
30307        );
30308
30309        assert_eq!(report.symbols.len(), 1);
30310        assert!(report.symbols[0].handle.starts_with("sfam-"));
30311        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
30312        assert_eq!(report.symbols[0].name, "alpha_hel...");
30313        assert_eq!(report.symbols[0].file, "src/lib.rs");
30314        assert!(report.symbols[0].expand.contains("tsift search"));
30315    }
30316
30317    #[test]
30318    fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
30319        let dir = tempfile::tempdir().unwrap();
30320        let src_dir = dir.path().join("src");
30321        fs::create_dir_all(&src_dir).unwrap();
30322        let source = "fn alpha_helper() {\n    beta();\n}\n";
30323        let file = src_dir.join("lib.rs");
30324        fs::write(&file, source).unwrap();
30325        let body_start = source.find("{\n").unwrap() + 1;
30326        let body_end = source.rfind("\n}").unwrap() + 1;
30327
30328        let response = empty_search_response(dir.path(), "lexical");
30329        let symbol_hits = vec![index::SymbolHit {
30330            name: "alpha_helper".to_string(),
30331            kind: "function".to_string(),
30332            language: "rust".to_string(),
30333            file: file.to_string_lossy().to_string(),
30334            line: 0,
30335            end_line: Some(2),
30336            node_kind: Some("function_item".to_string()),
30337            start_byte: Some(0),
30338            end_byte: Some(i64::try_from(source.len()).unwrap()),
30339            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30340            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30341            tags: Some("alpha,helper".to_string()),
30342            score: 0.98,
30343            match_type: "exact_name".to_string(),
30344            tagpath_handle: None,
30345        }];
30346
30347        let report = build_relative_search_budget_report(
30348            "alpha helper",
30349            "lexical",
30350            dir.path(),
30351            &response,
30352            &symbol_hits,
30353            ResponseBudget::new(Some(5), Some(96)),
30354            &SearchFacetFilters::default(),
30355        );
30356
30357        let symbol = &report.symbols[0];
30358        assert_eq!(symbol.language, "rust");
30359        assert_eq!(symbol.end_line, Some(2));
30360        let ast = symbol
30361            .ast
30362            .as_ref()
30363            .expect("search symbol preview should expose an AST span artifact");
30364        assert_eq!(ast.artifact_kind, "ast_span");
30365        assert!(ast.span.handle.starts_with("span-"));
30366        assert_eq!(ast.span.node_kind, "function_item");
30367        assert_eq!(ast.span.start_byte, 0);
30368        assert_eq!(ast.span.end_byte, source.len());
30369        assert_eq!(ast.span.body_start_byte, Some(body_start));
30370        assert_eq!(ast.span.body_end_byte, Some(body_end));
30371        assert!(ast.expand.source_window.contains("source-read"));
30372        assert!(
30373            ast.expand
30374                .source_body
30375                .as_ref()
30376                .unwrap()
30377                .contains("source-read")
30378        );
30379        assert!(ast.expand.symbol_read.contains("symbol-read"));
30380        assert!(ast.expand.markdown_ast.is_none());
30381    }
30382
30383    #[test]
30384    fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
30385        let dir = tempfile::tempdir().unwrap();
30386        let source = "# Guide\n\n## Install\n\n- Run setup.\n";
30387        let file = dir.path().join("README.md");
30388        fs::write(&file, source).unwrap();
30389        let heading_start = source.find("## Install").unwrap();
30390        let heading_end = source.len();
30391
30392        let response = empty_search_response(dir.path(), "lexical");
30393        let symbol_hits = vec![index::SymbolHit {
30394            name: "Install".to_string(),
30395            kind: "heading".to_string(),
30396            language: "markdown".to_string(),
30397            file: file.to_string_lossy().to_string(),
30398            line: 2,
30399            end_line: Some(4),
30400            node_kind: Some("atx_heading".to_string()),
30401            start_byte: Some(i64::try_from(heading_start).unwrap()),
30402            end_byte: Some(i64::try_from(heading_end).unwrap()),
30403            body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
30404            body_end_byte: Some(i64::try_from(heading_end).unwrap()),
30405            tags: Some("install".to_string()),
30406            score: 1.0,
30407            match_type: "exact_name".to_string(),
30408            tagpath_handle: None,
30409        }];
30410
30411        let report = build_relative_search_budget_report(
30412            "Install",
30413            "lexical",
30414            dir.path(),
30415            &response,
30416            &symbol_hits,
30417            ResponseBudget::new(Some(5), Some(96)),
30418            &SearchFacetFilters::default(),
30419        );
30420
30421        let ast = report.symbols[0]
30422            .ast
30423            .as_ref()
30424            .expect("Markdown search symbol should expose an AST span artifact");
30425        assert_eq!(ast.span.node_kind, "atx_heading");
30426        assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
30427        let markdown_ast = ast
30428            .expand
30429            .markdown_ast
30430            .as_ref()
30431            .expect("Markdown symbols should include markdown-ast expansion");
30432        assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
30433        assert!(markdown_ast.contains("--node"), "{markdown_ast}");
30434        assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
30435        assert!(ast.expand.source_window.contains("source-read"));
30436        assert!(ast.expand.symbol_read.contains("symbol-read"));
30437    }
30438
30439    #[test]
30440    fn search_budget_report_exposes_markdown_embedded_code_symbols() {
30441        let dir = tempfile::tempdir().unwrap();
30442        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30443        let file = dir.path().join("README.md");
30444        fs::write(&file, source).unwrap();
30445        let fence_start = source.find("```rust").unwrap();
30446        let body_start = source.find("fn sample").unwrap();
30447        let body_end = body_start + "fn sample() {}\n".len();
30448
30449        let response = empty_search_response(dir.path(), "lexical");
30450        let symbol_hits = vec![index::SymbolHit {
30451            name: "rust".to_string(),
30452            kind: "code_block".to_string(),
30453            language: "markdown".to_string(),
30454            file: file.to_string_lossy().to_string(),
30455            line: 2,
30456            end_line: Some(4),
30457            node_kind: Some("fenced_code_block".to_string()),
30458            start_byte: Some(i64::try_from(fence_start).unwrap()),
30459            end_byte: Some(i64::try_from(source.len()).unwrap()),
30460            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30461            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30462            tags: Some("rust".to_string()),
30463            score: 1.0,
30464            match_type: "exact_name".to_string(),
30465            tagpath_handle: None,
30466        }];
30467
30468        let report = build_relative_search_budget_report(
30469            "rust",
30470            "lexical",
30471            dir.path(),
30472            &response,
30473            &symbol_hits,
30474            ResponseBudget::new(Some(5), Some(96)),
30475            &SearchFacetFilters::default(),
30476        );
30477
30478        let embedded = &report.symbols[0]
30479            .ast
30480            .as_ref()
30481            .unwrap()
30482            .span
30483            .markdown
30484            .as_ref()
30485            .unwrap()
30486            .embedded_symbols;
30487        assert_eq!(embedded.len(), 1);
30488        assert_eq!(embedded[0].name, "sample");
30489        assert_eq!(embedded[0].kind, "function");
30490        assert_eq!(embedded[0].language, "rust");
30491        assert_eq!(embedded[0].node_kind, "function_item");
30492        assert!(embedded[0].handle.starts_with("span-"));
30493        assert_eq!(embedded[0].start_byte, body_start);
30494        assert_eq!(embedded[0].start_line, 4);
30495    }
30496
30497    fn test_lexical_search_hit(
30498        path: &Path,
30499        rank: usize,
30500        score: f64,
30501        snippet: &str,
30502    ) -> sift::SearchHit {
30503        sift::SearchHit {
30504            artifact_id: format!("hit-{rank}"),
30505            artifact_kind: sift::ContextArtifactKind::File,
30506            budget: sift::ArtifactBudget::from_text(snippet, 1),
30507            confidence: sift::ScoreConfidence::High,
30508            freshness: sift::ArtifactFreshness {
30509                modified_unix_secs: None,
30510                observed_unix_secs: 0,
30511            },
30512            location: Some("line 1".to_string()),
30513            path: path.to_string_lossy().to_string(),
30514            provenance: sift::ArtifactProvenance {
30515                adapter: sift::AcquisitionAdapterKind::FileSystem,
30516                source: "test lexical hit".to_string(),
30517                synthetic: false,
30518            },
30519            rank,
30520            score,
30521            snippet: snippet.to_string(),
30522        }
30523    }
30524
30525    fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
30526        summarize::Summary {
30527            id: 0,
30528            symbol_name: symbol_name.to_string(),
30529            file_path: file_path.to_string(),
30530            content_hash: "hash".to_string(),
30531            summary: summary.to_string(),
30532            entities: None,
30533            relationships: None,
30534            concept_labels: None,
30535            extracted_at: "2026-06-02T00:00:00Z".to_string(),
30536            model: "test".to_string(),
30537            tokens_input: None,
30538            tokens_output: None,
30539        }
30540    }
30541
30542    #[test]
30543    fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
30544        let dir = tempfile::tempdir().unwrap();
30545        let src_dir = dir.path().join("src");
30546        fs::create_dir_all(&src_dir).unwrap();
30547        let source = "fn alpha_helper() {}\n";
30548        let file = src_dir.join("lib.rs");
30549        let broad_file = dir.path().join("README.md");
30550        fs::write(&file, source).unwrap();
30551        fs::write(
30552            &broad_file,
30553            "alpha helper alpha helper alpha helper in prose\n",
30554        )
30555        .unwrap();
30556
30557        let mut response = empty_search_response(dir.path(), "lexical");
30558        response.hits.push(test_lexical_search_hit(
30559            &broad_file,
30560            1,
30561            240.0,
30562            "alpha helper alpha helper alpha helper in prose",
30563        ));
30564        let symbol_hits = vec![index::SymbolHit {
30565            name: "alpha_helper".to_string(),
30566            kind: "function".to_string(),
30567            language: "rust".to_string(),
30568            file: file.to_string_lossy().to_string(),
30569            line: 0,
30570            end_line: Some(0),
30571            node_kind: Some("function_item".to_string()),
30572            start_byte: Some(0),
30573            end_byte: Some(i64::try_from(source.len()).unwrap()),
30574            body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30575            body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30576            tags: Some("alpha,helper".to_string()),
30577            score: 0.8,
30578            match_type: "all_tags".to_string(),
30579            tagpath_handle: None,
30580        }];
30581
30582        let report = build_relative_search_budget_report(
30583            "alpha helper",
30584            "lexical",
30585            dir.path(),
30586            &response,
30587            &symbol_hits,
30588            ResponseBudget::new(Some(5), Some(128)),
30589            &SearchFacetFilters::default(),
30590        );
30591
30592        assert_eq!(report.ranked[0].source, "symbol_span");
30593        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30594        assert!(report.ranked[0].score > report.ranked[1].score);
30595        assert_eq!(report.ranked[1].source, "lexical_file");
30596    }
30597
30598    #[test]
30599    fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
30600        let dir = tempfile::tempdir().unwrap();
30601        let src_dir = dir.path().join("src");
30602        fs::create_dir_all(&src_dir).unwrap();
30603        let source = "fn alpha_helper() {\n    let needle = \"needle\";\n}\n\nfn other() {}\n";
30604        let file = src_dir.join("lib.rs");
30605        fs::write(&file, source).unwrap();
30606
30607        let mut response = empty_search_response(dir.path(), "exact");
30608        let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
30609        hit.location = Some("line 2".to_string());
30610        response.hits.push(hit);
30611
30612        let symbol_hits = vec![index::SymbolHit {
30613            name: "alpha_helper".to_string(),
30614            kind: "function".to_string(),
30615            language: "rust".to_string(),
30616            file: file.to_string_lossy().to_string(),
30617            line: 0,
30618            end_line: Some(2),
30619            node_kind: Some("function_item".to_string()),
30620            start_byte: Some(0),
30621            end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
30622            body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
30623            body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
30624            tags: Some("alpha,helper".to_string()),
30625            score: 0.9,
30626            match_type: "all_tags".to_string(),
30627            tagpath_handle: None,
30628        }];
30629
30630        let report = build_relative_search_budget_report(
30631            "needle",
30632            "exact",
30633            dir.path(),
30634            &response,
30635            &symbol_hits,
30636            ResponseBudget::new(Some(5), Some(128)),
30637            &SearchFacetFilters::default(),
30638        );
30639
30640        let hit = &report.hits[0];
30641        assert_eq!(hit.line, Some(2));
30642        let source_handle = hit
30643            .source_handle
30644            .as_ref()
30645            .expect("exact hit should expose a bounded source_handle window");
30646        assert!(source_handle.handle.starts_with("xwin-"));
30647        assert_eq!(source_handle.kind, "source_handle");
30648        assert_eq!(source_handle.file, "src/lib.rs");
30649        assert_eq!(source_handle.start_line, 1);
30650        assert_eq!(source_handle.end_line, 3);
30651        assert!(source_handle.expand.contains("source-read"));
30652
30653        let containing_symbol = hit
30654            .containing_symbol
30655            .as_ref()
30656            .expect("exact hit should expose its containing symbol when indexed");
30657        assert_eq!(containing_symbol.name, "alpha_helper");
30658        assert_eq!(containing_symbol.kind, "function");
30659        assert_eq!(containing_symbol.line, 1);
30660        assert_eq!(containing_symbol.end_line, Some(3));
30661        assert!(containing_symbol.expand.contains("symbol-read"));
30662
30663        let lexical_rank = report
30664            .ranked
30665            .iter()
30666            .find(|item| item.source == "lexical_file")
30667            .expect("ranked preview should retain the lexical retrieval handle");
30668        assert!(
30669            lexical_rank
30670                .reasons
30671                .iter()
30672                .any(|reason| reason == "source_handle")
30673        );
30674        assert!(
30675            lexical_rank
30676                .reasons
30677                .iter()
30678                .any(|reason| reason == "containing_symbol")
30679        );
30680    }
30681
30682    #[test]
30683    fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
30684        let dir = tempfile::tempdir().unwrap();
30685        let src_dir = dir.path().join("src");
30686        let tests_dir = dir.path().join("tests");
30687        fs::create_dir_all(&src_dir).unwrap();
30688        fs::create_dir_all(&tests_dir).unwrap();
30689        let source_file = src_dir.join("lib.rs");
30690        let test_file = tests_dir.join("alpha_test.rs");
30691        fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
30692        fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
30693
30694        let response = empty_search_response(dir.path(), "lexical");
30695        let symbol_hits = vec![
30696            index::SymbolHit {
30697                name: "alpha_helper_test".to_string(),
30698                kind: "function".to_string(),
30699                language: "rust".to_string(),
30700                file: test_file.to_string_lossy().to_string(),
30701                line: 1,
30702                end_line: Some(1),
30703                node_kind: Some("function_item".to_string()),
30704                start_byte: Some(8),
30705                end_byte: Some(33),
30706                body_start_byte: Some(31),
30707                body_end_byte: Some(31),
30708                tags: Some("alpha,helper,test".to_string()),
30709                score: 1.0,
30710                match_type: "exact_name".to_string(),
30711                tagpath_handle: None,
30712            },
30713            index::SymbolHit {
30714                name: "alpha_helper".to_string(),
30715                kind: "function".to_string(),
30716                language: "rust".to_string(),
30717                file: source_file.to_string_lossy().to_string(),
30718                line: 0,
30719                end_line: Some(0),
30720                node_kind: Some("function_item".to_string()),
30721                start_byte: Some(0),
30722                end_byte: Some(20),
30723                body_start_byte: Some(18),
30724                body_end_byte: Some(18),
30725                tags: Some("alpha,helper".to_string()),
30726                score: 0.78,
30727                match_type: "all_tags".to_string(),
30728                tagpath_handle: None,
30729            },
30730        ];
30731
30732        let report = build_relative_search_budget_report(
30733            "alpha helper",
30734            "lexical",
30735            dir.path(),
30736            &response,
30737            &symbol_hits,
30738            ResponseBudget::new(Some(5), Some(128)),
30739            &SearchFacetFilters::default(),
30740        );
30741
30742        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30743        assert_eq!(report.ranked[0].path, "src/lib.rs");
30744        assert!(
30745            report.ranked[0]
30746                .reasons
30747                .iter()
30748                .any(|reason| reason == "definition_kind")
30749        );
30750        assert!(
30751            report.ranked[0]
30752                .reasons
30753                .iter()
30754                .any(|reason| reason == "source_path")
30755        );
30756        let test_rank = report
30757            .ranked
30758            .iter()
30759            .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
30760            .expect("test symbol should still be present in the ranked preview");
30761        assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
30762    }
30763
30764    #[test]
30765    fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
30766        let dir = tempfile::tempdir().unwrap();
30767        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30768        let file = dir.path().join("README.md");
30769        fs::write(&file, source).unwrap();
30770        let summary_db =
30771            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
30772        summary_db
30773            .insert(&test_summary(
30774                "rust",
30775                "README.md",
30776                "Rust fence contains a sample function.",
30777            ))
30778            .unwrap();
30779
30780        let fence_start = source.find("```rust").unwrap();
30781        let body_start = source.find("fn sample").unwrap();
30782        let body_end = body_start + "fn sample() {}\n".len();
30783        let response = empty_search_response(dir.path(), "lexical");
30784        let symbol_hits = vec![index::SymbolHit {
30785            name: "rust".to_string(),
30786            kind: "code_block".to_string(),
30787            language: "markdown".to_string(),
30788            file: file.to_string_lossy().to_string(),
30789            line: 2,
30790            end_line: Some(4),
30791            node_kind: Some("fenced_code_block".to_string()),
30792            start_byte: Some(i64::try_from(fence_start).unwrap()),
30793            end_byte: Some(i64::try_from(source.len()).unwrap()),
30794            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30795            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30796            tags: Some("rust".to_string()),
30797            score: 1.0,
30798            match_type: "exact_name".to_string(),
30799            tagpath_handle: None,
30800        }];
30801
30802        let report = build_relative_search_budget_report(
30803            "rust",
30804            "lexical",
30805            dir.path(),
30806            &response,
30807            &symbol_hits,
30808            ResponseBudget::new(Some(5), Some(128)),
30809            &SearchFacetFilters::default(),
30810        );
30811
30812        let symbol = &report.symbols[0];
30813        assert_eq!(symbol.summary_refs, 1);
30814        assert_eq!(symbol.graph_neighbors, 1);
30815        assert!(
30816            report.ranked[0]
30817                .reasons
30818                .iter()
30819                .any(|reason| reason == "summary_refs:1")
30820        );
30821        assert!(
30822            report.ranked[0]
30823                .reasons
30824                .iter()
30825                .any(|reason| reason == "graph_neighbors:1")
30826        );
30827    }
30828
30829    fn markdown_search_facet_fixture() -> tempfile::TempDir {
30830        let dir = tempfile::tempdir().unwrap();
30831        let source = r#"# Guide
30832
30833## Install
30834
30835- Run setup.
30836  - Confirm setup.
30837
30838```rust
30839fn sample() {}
30840```
30841"#;
30842        fs::write(dir.path().join("README.md"), source).unwrap();
30843        let index_dir = dir.path().join(".tsift");
30844        fs::create_dir_all(&index_dir).unwrap();
30845        run_index_update(
30846            &index_dir.join("index.db"),
30847            dir.path(),
30848            "indexing markdown search facet fixture".to_string(),
30849            dir.path(),
30850            None,
30851            false,
30852            false,
30853        )
30854        .unwrap();
30855        dir
30856    }
30857
30858    fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
30859        let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
30860        db.symbol_search(query, 20).unwrap()
30861    }
30862
30863    #[test]
30864    fn search_facet_filters_match_scalar_symbol_fields() {
30865        let dir = tempfile::tempdir().unwrap();
30866        let hits = vec![
30867            index::SymbolHit {
30868                name: "alpha_helper".to_string(),
30869                kind: "function".to_string(),
30870                language: "rust".to_string(),
30871                file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
30872                line: 0,
30873                end_line: None,
30874                node_kind: Some("function_item".to_string()),
30875                start_byte: None,
30876                end_byte: None,
30877                body_start_byte: None,
30878                body_end_byte: None,
30879                tags: None,
30880                score: 1.0,
30881                match_type: "exact_name".to_string(),
30882                tagpath_handle: None,
30883            },
30884            index::SymbolHit {
30885                name: "Install".to_string(),
30886                kind: "heading".to_string(),
30887                language: "markdown".to_string(),
30888                file: dir.path().join("README.md").to_string_lossy().to_string(),
30889                line: 0,
30890                end_line: None,
30891                node_kind: Some("atx_heading".to_string()),
30892                start_byte: None,
30893                end_byte: None,
30894                body_start_byte: None,
30895                body_end_byte: None,
30896                tags: None,
30897                score: 0.9,
30898                match_type: "exact_name".to_string(),
30899                tagpath_handle: None,
30900            },
30901        ];
30902
30903        let filtered = apply_search_facet_filters(
30904            dir.path(),
30905            hits,
30906            &SearchFacetFilters {
30907                languages: vec!["rust".to_string()],
30908                kinds: vec!["function".to_string()],
30909                node_kinds: vec!["function_item".to_string()],
30910                ..SearchFacetFilters::default()
30911            },
30912        );
30913
30914        assert_eq!(filtered.len(), 1);
30915        assert_eq!(filtered[0].name, "alpha_helper");
30916    }
30917
30918    #[test]
30919    fn search_facet_filters_match_markdown_sections_and_block_metadata() {
30920        let dir = markdown_search_facet_fixture();
30921
30922        let nested_list = apply_search_facet_filters(
30923            dir.path(),
30924            markdown_search_facet_hits(dir.path(), "setup"),
30925            &SearchFacetFilters {
30926                sections: vec!["Install".to_string()],
30927                parents: vec!["Run setup.".to_string()],
30928                list_depths: vec![1],
30929                ..SearchFacetFilters::default()
30930            },
30931        );
30932        assert_eq!(nested_list.len(), 1);
30933        assert_eq!(nested_list[0].name, "Confirm setup.");
30934
30935        let parent_list = apply_search_facet_filters(
30936            dir.path(),
30937            markdown_search_facet_hits(dir.path(), "setup"),
30938            &SearchFacetFilters {
30939                children: vec!["Confirm setup.".to_string()],
30940                ..SearchFacetFilters::default()
30941            },
30942        );
30943        assert_eq!(parent_list.len(), 1);
30944        assert_eq!(parent_list[0].name, "Run setup.");
30945
30946        let heading = apply_search_facet_filters(
30947            dir.path(),
30948            markdown_search_facet_hits(dir.path(), "Install"),
30949            &SearchFacetFilters {
30950                heading_levels: vec![2],
30951                node_kinds: vec!["atx_heading".to_string()],
30952                ..SearchFacetFilters::default()
30953            },
30954        );
30955        assert_eq!(heading.len(), 1);
30956        assert_eq!(heading[0].name, "Install");
30957
30958        let fence = apply_search_facet_filters(
30959            dir.path(),
30960            markdown_search_facet_hits(dir.path(), "rust"),
30961            &SearchFacetFilters {
30962                fence_languages: vec!["rust".to_string()],
30963                kinds: vec!["code_block".to_string()],
30964                ..SearchFacetFilters::default()
30965            },
30966        );
30967        assert_eq!(fence.len(), 1);
30968        assert_eq!(fence[0].kind, "code_block");
30969
30970        let embedded_child = apply_search_facet_filters(
30971            dir.path(),
30972            markdown_search_facet_hits(dir.path(), "rust"),
30973            &SearchFacetFilters {
30974                children: vec!["sample".to_string()],
30975                kinds: vec!["code_block".to_string()],
30976                ..SearchFacetFilters::default()
30977            },
30978        );
30979        assert_eq!(embedded_child.len(), 1);
30980        assert_eq!(embedded_child[0].name, "rust");
30981    }
30982
30983    #[test]
30984    fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
30985        let response = empty_search_response(Path::new("/repo"), "lexical");
30986        let symbol_hits = vec![
30987            index::SymbolHit {
30988                name: "alpha_helper".to_string(),
30989                kind: "function".to_string(),
30990                language: "rust".to_string(),
30991                file: "/repo/src/lib.rs".to_string(),
30992                line: 12,
30993                end_line: None,
30994                node_kind: None,
30995                start_byte: None,
30996                end_byte: None,
30997                body_start_byte: None,
30998                body_end_byte: None,
30999                tags: Some("alpha,helper".to_string()),
31000                score: 0.98,
31001                match_type: "exact_name".to_string(),
31002                tagpath_handle: None,
31003            },
31004            index::SymbolHit {
31005                name: "alphaHelper".to_string(),
31006                kind: "method".to_string(),
31007                language: "rust".to_string(),
31008                file: "/repo/src/main.rs".to_string(),
31009                line: 34,
31010                end_line: None,
31011                node_kind: None,
31012                start_byte: None,
31013                end_byte: None,
31014                body_start_byte: None,
31015                body_end_byte: None,
31016                tags: Some("alpha,helper".to_string()),
31017                score: 0.93,
31018                match_type: "tag_overlap".to_string(),
31019                tagpath_handle: None,
31020            },
31021            index::SymbolHit {
31022                name: "alpha_helper".to_string(),
31023                kind: "function".to_string(),
31024                language: "rust".to_string(),
31025                file: "/repo/src/worker.rs".to_string(),
31026                line: 56,
31027                end_line: None,
31028                node_kind: None,
31029                start_byte: None,
31030                end_byte: None,
31031                body_start_byte: None,
31032                body_end_byte: None,
31033                tags: Some("alpha,helper".to_string()),
31034                score: 0.91,
31035                match_type: "tag_overlap".to_string(),
31036                tagpath_handle: None,
31037            },
31038        ];
31039
31040        let report = build_relative_search_budget_report(
31041            "alpha helper",
31042            "lexical",
31043            Path::new("/repo"),
31044            &response,
31045            &symbol_hits,
31046            ResponseBudget::new(Some(5), Some(48)),
31047            &SearchFacetFilters::default(),
31048        );
31049
31050        assert_eq!(report.symbol_total, 1);
31051        assert_eq!(report.raw_symbol_total, 3);
31052        assert_eq!(report.symbols.len(), 1);
31053        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
31054        assert_eq!(report.symbols[0].match_count, 3);
31055        assert_eq!(report.symbols[0].surface_count, 2);
31056        assert_eq!(report.symbols[0].file_count, 3);
31057        assert_eq!(
31058            report.symbols[0].surface_examples,
31059            vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
31060        );
31061        assert!(report.symbols[0].name.contains("(+1 variant)"));
31062        assert!(report.symbols[0].file.contains("(+2 files)"));
31063        assert!(report.symbols[0].expand.contains("tsift search"));
31064        assert!(report.symbols[0].expand.contains("alpha helper"));
31065    }
31066
31067    #[test]
31068    fn search_budget_report_carries_active_filters() {
31069        let response = empty_search_response(Path::new("/repo"), "lexical");
31070        let symbol_hits = vec![index::SymbolHit {
31071            name: "alpha_helper".to_string(),
31072            kind: "function".to_string(),
31073            language: "rust".to_string(),
31074            file: "/repo/src/lib.rs".to_string(),
31075            line: 12,
31076            end_line: None,
31077            node_kind: Some("function_item".to_string()),
31078            start_byte: None,
31079            end_byte: None,
31080            body_start_byte: None,
31081            body_end_byte: None,
31082            tags: Some("alpha,helper".to_string()),
31083            score: 0.98,
31084            match_type: "exact_name".to_string(),
31085            tagpath_handle: None,
31086        }];
31087        let filters = SearchFacetFilters {
31088            languages: vec!["rust".to_string()],
31089            kinds: vec!["function".to_string()],
31090            node_kinds: vec!["function_item".to_string()],
31091            ..SearchFacetFilters::default()
31092        };
31093
31094        let report = build_relative_search_budget_report(
31095            "alpha helper",
31096            "lexical",
31097            Path::new("/repo"),
31098            &response,
31099            &symbol_hits,
31100            ResponseBudget::new(Some(5), Some(48)),
31101            &filters,
31102        );
31103
31104        assert_eq!(report.filters, filters);
31105        assert_eq!(
31106            search_facet_filters_summary(&report.filters),
31107            "lang=rust kind=function node-kind=function_item"
31108        );
31109    }
31110
31111    #[test]
31112    fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
31113        let mut response = empty_search_response(Path::new("/repo"), "lexical");
31114        response.indexed_artifacts = 450;
31115        let symbol_hits = vec![
31116            index::SymbolHit {
31117                name: "alpha_helper".to_string(),
31118                kind: "function".to_string(),
31119                language: "rust".to_string(),
31120                file: "/repo/src/lib.rs".to_string(),
31121                line: 12,
31122                end_line: None,
31123                node_kind: None,
31124                start_byte: None,
31125                end_byte: None,
31126                body_start_byte: None,
31127                body_end_byte: None,
31128                tags: Some("alpha,helper".to_string()),
31129                score: 0.98,
31130                match_type: "exact_name".to_string(),
31131                tagpath_handle: None,
31132            },
31133            index::SymbolHit {
31134                name: "beta_helper".to_string(),
31135                kind: "function".to_string(),
31136                language: "rust".to_string(),
31137                file: "/repo/src/beta.rs".to_string(),
31138                line: 21,
31139                end_line: None,
31140                node_kind: None,
31141                start_byte: None,
31142                end_byte: None,
31143                body_start_byte: None,
31144                body_end_byte: None,
31145                tags: Some("beta,helper".to_string()),
31146                score: 0.92,
31147                match_type: "tag_overlap".to_string(),
31148                tagpath_handle: None,
31149            },
31150        ];
31151
31152        let report = build_relative_search_budget_report(
31153            "helper",
31154            "lexical",
31155            Path::new("/repo"),
31156            &response,
31157            &symbol_hits,
31158            ResponseBudget::new(Some(1), Some(64)),
31159            &SearchFacetFilters::default(),
31160        );
31161
31162        let guard = report
31163            .scale_guard
31164            .as_ref()
31165            .expect("broad previews should emit a scale guard");
31166        assert_eq!(guard.level, "high-hit");
31167        assert_eq!(guard.signals.indexed_artifacts, 450);
31168        assert_eq!(guard.signals.raw_symbol_matches, 2);
31169        assert!(
31170            guard
31171                .narrow_commands
31172                .iter()
31173                .any(|command| command.contains("--exact"))
31174        );
31175        assert!(
31176            guard
31177                .narrow_commands
31178                .iter()
31179                .any(|command| command.contains("alpha helper"))
31180        );
31181        assert!(
31182            guard
31183                .narrow_commands
31184                .last()
31185                .unwrap()
31186                .contains("workflow search")
31187        );
31188    }
31189
31190    #[test]
31191    fn explain_budget_report_limits_edges_and_members() {
31192        let symbols = vec![index::StoredSymbol {
31193            name: "alpha_helper".to_string(),
31194            kind: "function".to_string(),
31195            language: "rust".to_string(),
31196            signature: None,
31197            file: "src/lib.rs".to_string(),
31198            line: 10,
31199            end_line: None,
31200            node_kind: None,
31201            start_byte: None,
31202            end_byte: None,
31203            body_start_byte: None,
31204            body_end_byte: None,
31205            parent_module: None,
31206            visibility: None,
31207            tags: None,
31208            tagpath_handle: None,
31209        }];
31210        let callers = vec![
31211            index::StoredEdge {
31212                caller_file: "src/main.rs".to_string(),
31213                caller_name: "main".to_string(),
31214                caller_line: 1,
31215                callee_name: "alpha_helper".to_string(),
31216                call_site_line: 3,
31217                tagpath_handle: None,
31218            },
31219            index::StoredEdge {
31220                caller_file: "src/worker.rs".to_string(),
31221                caller_name: "worker".to_string(),
31222                caller_line: 5,
31223                callee_name: "alpha_helper".to_string(),
31224                call_site_line: 8,
31225                tagpath_handle: None,
31226            },
31227        ];
31228        let community = graph::Community {
31229            id: 1,
31230            members: vec![
31231                graph::CommunityMember::new("alpha_helper"),
31232                graph::CommunityMember::new("main"),
31233                graph::CommunityMember::new("worker"),
31234            ],
31235            modularity_contribution: 0.5,
31236        };
31237
31238        let report = build_explain_budget_report(
31239            "alpha_helper",
31240            Path::new("/repo"),
31241            &symbols,
31242            &callers,
31243            2,
31244            false,
31245            &[],
31246            0,
31247            false,
31248            Some(&community),
31249            ResponseBudget::new(Some(1), Some(24)),
31250        );
31251
31252        assert_eq!(report.definitions.len(), 1);
31253        assert_eq!(report.callers.len(), 1);
31254        assert!(report.truncated);
31255        assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
31256        assert_eq!(
31257            report.definitions[0].tag_alias.as_deref(),
31258            Some("alpha/helper")
31259        );
31260        assert!(report.callers[0].handle.starts_with("ecall-"));
31261        assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
31262    }
31263
31264    #[test]
31265    fn session_review_next_context_budget_limits_lists() {
31266        let report = session_review::SessionReviewReport {
31267            root: "/repo".to_string(),
31268            target: "tasks/software/tsift.md".to_string(),
31269            target_kind: "file".to_string(),
31270            sessions_considered: 1,
31271            sessions_matched: 1,
31272            claude_sessions: 1,
31273            codex_sessions: 0,
31274            agent_doc_logs: 0,
31275            prompt_target_count: 2,
31276            command_groups: 0,
31277            file_groups: 2,
31278            symbol_groups: 1,
31279            failure_groups: 1,
31280            runtime_event_groups: 0,
31281            restart_churn_groups: 0,
31282            closeout_groups: 0,
31283            usage_samples: 1,
31284            prompt_tokens: 120,
31285            cached_input_tokens: 80,
31286            cache_creation_input_tokens: 0,
31287            output_tokens: 40,
31288            reasoning_output_tokens: 0,
31289            total_tokens: 240,
31290            cached_input_ratio: Some(40.0),
31291            largest_turn_total_tokens: 240,
31292            aggregate_cost: session_review::SessionReviewCostSummary {
31293                scope: "bounded_matched_sessions".to_string(),
31294                sessions: 1,
31295                usage_samples: 1,
31296                prompt_tokens: 120,
31297                cached_input_tokens: 80,
31298                cache_creation_input_tokens: 0,
31299                output_tokens: 40,
31300                reasoning_output_tokens: 0,
31301                total_tokens: 240,
31302                cached_input_ratio: Some(40.0),
31303                largest_turn_total_tokens: 240,
31304            },
31305            latest_session_cost: Some(session_review::SessionReviewCostSummary {
31306                scope: "latest_matched_session".to_string(),
31307                sessions: 1,
31308                usage_samples: 1,
31309                prompt_tokens: 120,
31310                cached_input_tokens: 80,
31311                cache_creation_input_tokens: 0,
31312                output_tokens: 40,
31313                reasoning_output_tokens: 0,
31314                total_tokens: 240,
31315                cached_input_ratio: Some(66.67),
31316                largest_turn_total_tokens: 240,
31317            }),
31318            prompt_cache_cross_run: None,
31319            prompt_cache_roi_scorecard: vec![],
31320            guardrails: vec![
31321                session_cost::SessionCostGuardrail {
31322                    kind: "cache_resend".to_string(),
31323                    severity: "warn".to_string(),
31324                    message: "cached input ratio was high".to_string(),
31325                    guidance: "compact or restart the session".to_string(),
31326                },
31327                session_cost::SessionCostGuardrail {
31328                    kind: "prompt_budget".to_string(),
31329                    severity: "warn".to_string(),
31330                    message: "largest prompt turn reached 999999 tokens".to_string(),
31331                    guidance: "compact the session before another large turn".to_string(),
31332                },
31333                session_cost::SessionCostGuardrail {
31334                    kind: "restart_loop".to_string(),
31335                    severity: "warn".to_string(),
31336                    message: "restart churn detected".to_string(),
31337                    guidance: "restart cleanly".to_string(),
31338                },
31339                session_cost::SessionCostGuardrail {
31340                    kind: "noop_closeout".to_string(),
31341                    severity: "warn".to_string(),
31342                    message: "commit_already_current appeared 8 times".to_string(),
31343                    guidance: "avoid reopening without new edits".to_string(),
31344                },
31345            ],
31346            loop_clusters: vec![session_cost::SessionCostLoopCluster {
31347                kind: "command_bundle".to_string(),
31348                label: "cargo test -> cargo build --release".to_string(),
31349                occurrences: 2,
31350                max_consecutive: 2,
31351            }],
31352            file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
31353                path: "src/lib.rs".to_string(),
31354                range: "12-40".to_string(),
31355                occurrences: 3,
31356                estimated_tokens: 1200,
31357                duplicate_estimated_tokens: 800,
31358                follow_up_commands: vec![
31359                    "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
31360                        .to_string(),
31361                ],
31362            }],
31363            prompt_targets: vec![
31364                session_review::SessionReviewPromptTarget {
31365                    text: "do one".to_string(),
31366                    occurrences: 1,
31367                },
31368                session_review::SessionReviewPromptTarget {
31369                    text: "do two".to_string(),
31370                    occurrences: 1,
31371                },
31372            ],
31373            commands: vec![],
31374            touched_files: vec![],
31375            touched_symbols: vec![],
31376            failures: vec![],
31377            runtime_events: vec![],
31378            restart_churn: vec![],
31379            closeout: vec![],
31380            largest_turns: vec![],
31381            sessions: vec![session_review::SessionReviewSession {
31382                source: "claude_jsonl".to_string(),
31383                path: "/tmp/session.jsonl".to_string(),
31384                matched_by: vec!["path".to_string()],
31385                modified_unix_secs: None,
31386                prompt_target_count: 2,
31387                command_groups: 0,
31388                file_groups: 2,
31389                symbol_groups: 1,
31390                failure_groups: 1,
31391                runtime_event_groups: 0,
31392                restart_churn_groups: 0,
31393                closeout_groups: 0,
31394                usage_samples: 1,
31395                prompt_tokens: 120,
31396                cached_input_tokens: 80,
31397                cache_creation_input_tokens: 0,
31398                output_tokens: 40,
31399                reasoning_output_tokens: 0,
31400                total_tokens: 240,
31401                largest_turn_total_tokens: 240,
31402            }],
31403            next_context: session_review::SessionReviewNextContext {
31404                target: "tasks/software/tsift.md".to_string(),
31405                active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
31406                last_verification: session_review::SessionReviewVerificationState {
31407                    status: "green".to_string(),
31408                    detail: "cargo test".to_string(),
31409                },
31410                touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
31411                touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
31412                unresolved_failures: vec![session_review::SessionReviewFailure {
31413                    kind: "timeout".to_string(),
31414                    message: "search timed out".to_string(),
31415                    occurrences: 1,
31416                    command: None,
31417                    session_path: None,
31418                }],
31419                agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
31420                    active_queue_prompt: Some(
31421                        "[#one] do one with enough detail to truncate".to_string(),
31422                    ),
31423                    live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
31424                    backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
31425                    review_rows: vec![
31426                        "[#review] review one".to_string(),
31427                        "[#review2] review two".to_string(),
31428                    ],
31429                    prompt_presets: vec![
31430                        "#spec-test-build-install-commit-push: update spec + tests"
31431                            .to_string(),
31432                        "#next-steps: collect follow-ups".to_string(),
31433                    ],
31434                    expansion_handles: vec![
31435                        session_review::SessionReviewAgentDocExpansionHandle {
31436                            handle: "adq-next-context".to_string(),
31437                            label: "refresh next-context".to_string(),
31438                            expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
31439                        },
31440                        session_review::SessionReviewAgentDocExpansionHandle {
31441                            handle: "adq-context-pack".to_string(),
31442                            label: "refresh context-pack".to_string(),
31443                            expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
31444                        },
31445                    ],
31446                }),
31447                prompt_cache_health: None,
31448                next_digest_commands: vec![
31449                    "tsift session-review --next-context tasks/software/tsift.md".to_string(),
31450                    "tsift diff-digest .".to_string(),
31451                    "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
31452                    "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
31453                ],
31454            },
31455            warnings: vec![],
31456        };
31457
31458        let budget_report = build_session_review_next_context_budget_report(
31459            &report,
31460            ResponseBudget::new(Some(1), Some(12)),
31461            None,
31462        );
31463
31464        assert!(budget_report.truncated);
31465        assert_eq!(budget_report.prompt_targets, vec!["do one"]);
31466        assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
31467        assert!(
31468            budget_report.touched_symbol_refs[0]
31469                .handle
31470                .starts_with("ncsym-")
31471        );
31472        assert_eq!(
31473            budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
31474            Some("alpha/helper")
31475        );
31476        assert!(
31477            budget_report.unresolved_failures[0]
31478                .handle
31479                .starts_with("snf-")
31480        );
31481        assert_eq!(budget_report.next_digest_commands.len(), 4);
31482        assert_eq!(
31483            budget_report.next_digest_commands[2],
31484            "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
31485        );
31486        let queue = budget_report
31487            .agent_doc_queue
31488            .as_ref()
31489            .expect("agent-doc queue budget profile should be present");
31490        assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
31491        assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
31492        assert_eq!(queue.review_row_total, 2);
31493        assert_eq!(queue.prompt_presets.len(), 1);
31494        assert_eq!(queue.expansion_handles.len(), 2);
31495        assert!(queue.truncated);
31496        assert_eq!(budget_report.next_token_actions.len(), 1);
31497        assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
31498
31499        let full_action_report = build_session_review_next_context_budget_report(
31500            &report,
31501            ResponseBudget::new(Some(6), Some(120)),
31502            None,
31503        );
31504        assert_eq!(
31505            full_action_report
31506                .next_token_actions
31507                .iter()
31508                .map(|action| action.kind.as_str())
31509                .collect::<Vec<_>>(),
31510            vec![
31511                "prompt_budget",
31512                "cache_resend",
31513                "repeated_raw_read",
31514                "repeated_command_bundle",
31515                "restart_loop",
31516                "noop_closeout"
31517            ]
31518        );
31519        assert_eq!(
31520            full_action_report.next_token_actions[0]
31521                .compact_command
31522                .as_deref(),
31523            Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
31524        );
31525        assert_eq!(
31526            full_action_report.next_token_actions[0]
31527                .restart_command
31528                .as_deref(),
31529            Some("agent-doc start \"tasks/software/tsift.md\"")
31530        );
31531        assert!(
31532            full_action_report.next_token_actions[0]
31533                .digest_commands
31534                .iter()
31535                .any(|command| command
31536                    == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
31537        );
31538        let raw_read_action = full_action_report
31539            .next_token_actions
31540            .iter()
31541            .find(|action| action.kind == "repeated_raw_read")
31542            .expect("raw read action");
31543        assert!(
31544            raw_read_action.rewrite_commands.iter().any(
31545                |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
31546            ),
31547            "raw read rewrite commands: {:?}",
31548            raw_read_action.rewrite_commands
31549        );
31550        assert!(raw_read_action.rewrite_commands.iter().any(|command| command
31551        == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
31552        let command_bundle_action = full_action_report
31553            .next_token_actions
31554            .iter()
31555            .find(|action| action.kind == "repeated_command_bundle")
31556            .expect("command bundle action");
31557        assert!(
31558            command_bundle_action
31559                .rewrite_commands
31560                .iter()
31561                .any(|command| command == "tsift rewrite --run \"cargo test\"")
31562        );
31563        assert!(
31564            command_bundle_action
31565                .rewrite_commands
31566                .iter()
31567                .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
31568        );
31569    }
31570
31571    #[test]
31572    fn context_pack_diff_preview_limits_files_and_symbols() {
31573        let report = diff_digest::DiffDigestReport {
31574            root: "/repo".to_string(),
31575            mode: diff_digest::DiffDigestMode::WorkingTree,
31576            revision: None,
31577            files_changed: 2,
31578            files_with_current_summaries: 1,
31579            symbols_touched: 3,
31580            call_edges_added: 1,
31581            call_edges_removed: 0,
31582            files: vec![
31583                diff_digest::DiffDigestFile {
31584                    path: "src/lib.rs".to_string(),
31585                    status: diff_digest::DiffDigestFileStatus::Modified,
31586                    touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
31587                    summary_state: diff_digest::DiffDigestSummaryState::Current,
31588                    current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31589                        symbol: "alpha_helper".to_string(),
31590                        summary: "alpha helper handles the main alpha workflow".to_string(),
31591                    }],
31592                    added_call_edges: vec!["alpha->beta".to_string()],
31593                    removed_call_edges: vec![],
31594                    warnings: vec!["stale parse".to_string()],
31595                },
31596                diff_digest::DiffDigestFile {
31597                    path: "src/main.rs".to_string(),
31598                    status: diff_digest::DiffDigestFileStatus::Added,
31599                    touched_symbols: vec!["main".to_string()],
31600                    summary_state: diff_digest::DiffDigestSummaryState::Missing,
31601                    current_summaries: vec![],
31602                    added_call_edges: vec![],
31603                    removed_call_edges: vec![],
31604                    warnings: vec![],
31605                },
31606            ],
31607        };
31608
31609        let preview =
31610            build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
31611
31612        assert!(preview.truncated);
31613        assert_eq!(preview.files.len(), 1);
31614        assert_eq!(preview.files[0].path, "src/lib.rs");
31615        assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
31616        assert!(
31617            preview.files[0].touched_symbol_refs[0]
31618                .handle
31619                .starts_with("cdsym-")
31620        );
31621        assert_eq!(
31622            preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
31623            Some("alpha/he...")
31624        );
31625        assert!(
31626            preview.files[0].summary_refs[0]
31627                .handle
31628                .starts_with("cdsum-")
31629        );
31630        assert_eq!(
31631            preview.files[0].summary_refs[0].tag_alias.as_deref(),
31632            Some("alpha/he...")
31633        );
31634        assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
31635        assert_eq!(
31636            preview.files[0].summary_refs[0].expand,
31637            "tsift summarize --file \"src/lib.rs\""
31638        );
31639        assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
31640    }
31641
31642    #[test]
31643    fn context_pack_status_reminders_include_stale_index_state() {
31644        let dir = setup_graph_index();
31645        std::thread::sleep(std::time::Duration::from_millis(50));
31646        std::fs::write(
31647            dir.path().join("main.rs"),
31648            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
31649        )
31650        .unwrap();
31651
31652        let reminders = context_pack_status_reminders(dir.path());
31653
31654        assert_eq!(reminders.len(), 1);
31655        assert!(reminders[0].contains("index stale"));
31656        assert!(reminders[0].contains("tsift index ."));
31657    }
31658
31659    // #gdbgatecold regression-lock: the trusted context-pack pipeline must
31660    // share its index-inspection across `prepare_agent_doc_index_gate` and
31661    // `context_pack_status_reminders` (both call `IndexDb::inspect_read_only`
31662    // on the same `(root, .tsift/index.db)` key). With the scope guard
31663    // active in `build_context_pack_report_with_profile`, the second call
31664    // hits the cache, so we should record one miss and at least one hit.
31665    #[test]
31666    fn build_context_pack_reuses_inspect_within_scope() {
31667        let dir = setup_graph_index();
31668        init_git_repo(dir.path());
31669        let _guard = index::InspectScopeGuard::new();
31670        let _ = build_context_pack_report(
31671            dir.path(),
31672            None,
31673            None,
31674            None,
31675            ResponseBudget::new(Some(2), Some(96)),
31676        )
31677        .unwrap();
31678        let (hits, misses) = index::inspect_scope_stats();
31679        assert!(
31680            hits >= 1,
31681            "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
31682        );
31683        assert!(
31684            misses >= 1,
31685            "expected at least one initial inspect miss (hits={hits}, misses={misses})"
31686        );
31687    }
31688
31689    // #gdbgatecold scope-isolation: outside of any scope, every call to
31690    // `IndexDb::inspect_read_only` must hit the disk fresh. This locks in
31691    // the contract that the search/status fast-paths never reuse a cached
31692    // inspection across consecutive top-level calls.
31693    #[test]
31694    fn inspect_read_only_outside_scope_does_not_cache() {
31695        let dir = setup_graph_index();
31696        let db_path = dir.path().join(".tsift/index.db");
31697        let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31698        let (hits, misses) = index::inspect_scope_stats();
31699        assert_eq!(
31700            (hits, misses),
31701            (0, 0),
31702            "no scope guard => no hits/misses recorded"
31703        );
31704        let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31705        let (hits, _) = index::inspect_scope_stats();
31706        assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
31707    }
31708
31709    #[test]
31710    fn context_pack_refreshes_stale_index_before_handoff() {
31711        let dir = setup_graph_index();
31712        init_git_repo(dir.path());
31713        std::thread::sleep(std::time::Duration::from_millis(50));
31714        std::fs::write(
31715            dir.path().join("main.rs"),
31716            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
31717        )
31718        .unwrap();
31719
31720        let report = build_context_pack_report(
31721            dir.path(),
31722            None,
31723            None,
31724            None,
31725            ResponseBudget::new(Some(2), Some(96)),
31726        )
31727        .unwrap();
31728
31729        assert!(
31730            report
31731                .status_reminders
31732                .iter()
31733                .any(|reminder| reminder.contains("index refreshed")
31734                    && reminder.contains("context-pack handoff")),
31735            "expected context-pack refresh diagnostic, got {:?}",
31736            report.status_reminders
31737        );
31738        assert!(
31739            !report
31740                .status_reminders
31741                .iter()
31742                .any(|reminder| reminder.contains("index stale")),
31743            "stale reminder should be gone after refresh: {:?}",
31744            report.status_reminders
31745        );
31746
31747        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
31748        let summary = db.compute_changes(dir.path()).unwrap();
31749        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
31750    }
31751
31752    #[test]
31753    fn context_pack_materializes_source_handles_into_graph_store() {
31754        let dir = tempfile::tempdir().unwrap();
31755        let packet = ExplorationPacket {
31756            budget: exploration_budget_for_counts(2, 1),
31757            relationship_map: vec![ExplorationRelation {
31758                from: "file:main.rs".to_string(),
31759                relation: "touches_symbol".to_string(),
31760                to: "symbol:helper".to_string(),
31761                label: Some("modified diff".to_string()),
31762            }],
31763            source_windows: vec![ExplorationSourceWindow {
31764                handle: "xwin-test".to_string(),
31765                file: "main.rs".to_string(),
31766                start: 1,
31767                end: 32,
31768                reason: "changed file".to_string(),
31769                expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
31770            }],
31771            worker_context: vec![ExplorationWorkerContext {
31772                handle: "xwrk-test".to_string(),
31773                target: "tasks/software/tsift.md".to_string(),
31774                summary: "do #kgnv".to_string(),
31775                expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
31776                    .to_string(),
31777            }],
31778            no_reread_guidance: "use windows".to_string(),
31779        };
31780
31781        let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
31782        assert_eq!(packet.source_windows[0].handle, "xwin-test");
31783
31784        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
31785        let source_handles = store.nodes_by_kind("source_handle").unwrap();
31786        assert_eq!(source_handles.len(), 1);
31787        assert_eq!(
31788            source_handles[0].properties.get("file"),
31789            Some(&"main.rs".to_string())
31790        );
31791        assert_eq!(
31792            store
31793                .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
31794                .unwrap()
31795                .len(),
31796            1
31797        );
31798        let worker_context = store.nodes_by_kind("worker_context").unwrap();
31799        assert_eq!(worker_context.len(), 1);
31800        assert_eq!(
31801            store
31802                .outgoing_edges("xwrk-test", Some("scopes_source"))
31803                .unwrap()
31804                .len(),
31805            1
31806        );
31807    }
31808
31809    #[test]
31810    fn context_pack_records_graph_orchestration_observability() {
31811        let dir = setup_traversal_project();
31812        init_git_repo(dir.path());
31813        let session = dir.path().join("tasks/software/tsift.md");
31814        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
31815
31816        let report = build_context_pack_report(
31817            &session,
31818            None,
31819            None,
31820            None,
31821            ResponseBudget::new(Some(4), Some(160)),
31822        )
31823        .unwrap();
31824
31825        assert_eq!(
31826            report.graph_orchestration.contract_version,
31827            CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
31828        );
31829        assert_eq!(
31830            report
31831                .graph_orchestration
31832                .projection_freshness
31833                .status
31834                .as_str(),
31835            "current"
31836        );
31837        assert!(!report.graph_orchestration.projection_hashes.is_empty());
31838        assert_eq!(report.graph_orchestration.readiness.status, "blocked");
31839        assert_eq!(
31840            report.graph_orchestration.readiness.reason,
31841            "summary_cache_empty"
31842        );
31843        assert!(report.graph_orchestration.readiness.fail_closed);
31844        assert!(
31845            report
31846                .graph_orchestration
31847                .readiness
31848                .next_commands
31849                .iter()
31850                .any(|command| command == "tsift summarize --extract ."),
31851            "{:?}",
31852            report.graph_orchestration.readiness.next_commands
31853        );
31854        assert!(
31855            report
31856                .graph_orchestration
31857                .evidence_packet_ids
31858                .iter()
31859                .all(|id| !id.starts_with("gevd-")),
31860            "evidence packet ids should be empty when readiness is blocked: {:?}",
31861            report.graph_orchestration.evidence_packet_ids
31862        );
31863        assert!(
31864            report
31865                .graph_orchestration
31866                .conflict_matrix_decisions
31867                .iter()
31868                .any(|decision| decision.contains("readiness blocked")),
31869            "conflict-matrix decisions should reference readiness block: {:?}",
31870            report.graph_orchestration.conflict_matrix_decisions
31871        );
31872        assert!(
31873            !report
31874                .graph_orchestration
31875                .follow_up_commands
31876                .iter()
31877                .any(|command| command.contains("conflict-matrix")),
31878            "conflict-matrix command should not appear when readiness is blocked: {:?}",
31879            report.graph_orchestration.follow_up_commands
31880        );
31881        assert!(
31882            report
31883                .graph_orchestration
31884                .follow_up_commands
31885                .iter()
31886                .any(|command| command == "tsift summarize --extract ."),
31887            "{:?}",
31888            report.graph_orchestration.follow_up_commands
31889        );
31890        assert!(
31891            !report
31892                .graph_orchestration
31893                .worker_ownership_blocks
31894                .is_empty()
31895        );
31896    }
31897
31898    #[test]
31899    fn convex_sync_report_chunks_upserts_and_tombstones() {
31900        let dir = setup_traversal_project();
31901        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
31902        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
31903        let mut snapshot = projection.to_convex_rows();
31904        snapshot.nodes.push(ConvexNodeRow {
31905            external_id: "stale-node".to_string(),
31906            kind: "backlog".to_string(),
31907            label: "stale".to_string(),
31908            properties: BTreeMap::new(),
31909            provenance: Vec::new(),
31910            freshness: None,
31911        });
31912        snapshot.edges.clear();
31913        snapshot.edges.push(ConvexEdgeRow {
31914            edge_key: "stale-edge".to_string(),
31915            from_external_id: "stale-node".to_string(),
31916            to_external_id: "stale-node".to_string(),
31917            kind: "mentions".to_string(),
31918            properties: BTreeMap::new(),
31919            provenance: Vec::new(),
31920            freshness: None,
31921        });
31922        let snapshot_path = dir.path().join("convex-snapshot.json");
31923        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31924
31925        let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
31926
31927        assert_eq!(report.freshness.status, "stale");
31928        assert!(report.freshness.fail_closed);
31929        assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
31930        assert!(
31931            report.edge_upserts.len() > 1,
31932            "snapshot without edges should upsert local edges"
31933        );
31934        assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
31935        assert_eq!(
31936            report.chunks.first().map(|chunk| chunk.operation.as_str()),
31937            Some("delete_edges"),
31938            "edge tombstones should be planned before node tombstones"
31939        );
31940        assert!(
31941            report
31942                .chunks
31943                .iter()
31944                .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
31945            "expected chunked edge upserts, got {:?}",
31946            report.chunks
31947        );
31948    }
31949
31950    #[test]
31951    fn convex_snapshot_validation_fails_closed_when_stale() {
31952        let dir = setup_traversal_project();
31953        build_traversal_graph(dir.path(), dir.path(), None).unwrap();
31954        let snapshot = ConvexProjectionRows::default();
31955        let snapshot_path = dir.path().join("empty-convex-snapshot.json");
31956        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31957
31958        let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
31959        assert!(
31960            err.to_string()
31961                .contains("Convex graph projection is not current"),
31962            "{err}"
31963        );
31964    }
31965
31966    #[test]
31967    fn convex_sync_report_marks_live_apply_mode_without_network() {
31968        let dir = setup_traversal_project();
31969        let report =
31970            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31971
31972        assert!(!report.dry_run);
31973        assert!(
31974            !report
31975                .diagnostics
31976                .iter()
31977                .any(|diagnostic| diagnostic.contains("dry-run only")),
31978            "apply-mode report should not claim dry-run diagnostics"
31979        );
31980        assert!(
31981            report
31982                .chunks
31983                .iter()
31984                .any(|chunk| chunk.operation == "upsert_nodes"),
31985            "live apply mode should still expose chunked idempotent operations"
31986        );
31987    }
31988
31989    #[test]
31990    fn convex_sync_apply_round_trips_with_http_backend() {
31991        use std::net::TcpListener;
31992        use std::sync::{Arc, Mutex};
31993
31994        let dir = setup_traversal_project();
31995        let report =
31996            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31997        let expected_chunks = report.chunks.len();
31998        assert!(expected_chunks > 0);
31999
32000        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
32001        let endpoint = format!("http://{}", listener.local_addr().unwrap());
32002        let operations = Arc::new(Mutex::new(Vec::<String>::new()));
32003        let server_operations = Arc::clone(&operations);
32004        let server = std::thread::spawn(move || {
32005            for _ in 0..expected_chunks {
32006                let (mut stream, _) = listener.accept().unwrap();
32007                let mut reader = BufReader::new(stream.try_clone().unwrap());
32008                let mut request_line = String::new();
32009                reader.read_line(&mut request_line).unwrap();
32010                assert!(request_line.starts_with("POST "));
32011
32012                let mut content_length = 0usize;
32013                loop {
32014                    let mut line = String::new();
32015                    reader.read_line(&mut line).unwrap();
32016                    if line == "\r\n" {
32017                        break;
32018                    }
32019                    if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
32020                        content_length = value.trim().parse().unwrap();
32021                    }
32022                }
32023
32024                let mut body = vec![0u8; content_length];
32025                reader.read_exact(&mut body).unwrap();
32026                let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
32027                server_operations
32028                    .lock()
32029                    .unwrap()
32030                    .push(request["operation"].as_str().unwrap().to_string());
32031
32032                let response = br#"{"status":"ok","message":"accepted"}"#;
32033                write!(
32034                    stream,
32035                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
32036                    response.len()
32037                )
32038                .unwrap();
32039                stream.write_all(response).unwrap();
32040            }
32041        });
32042
32043        cmd_convex_sync(
32044            ConvexSyncOptions {
32045                path: dir.path(),
32046                scope: None,
32047                snapshot: None,
32048                chunk_size: 100,
32049                remote_snapshot: false,
32050                apply: true,
32051                endpoint: Some(&endpoint),
32052                auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
32053            },
32054            OutputFormat {
32055                json_output: false,
32056                compact: true,
32057                pretty: false,
32058                terse: false,
32059                ultra_terse: false,
32060                schema: false,
32061                envelope: false,
32062            },
32063        )
32064        .unwrap();
32065        server.join().unwrap();
32066
32067        let operations = operations.lock().unwrap().clone();
32068        assert!(operations.contains(&"upsert_nodes".to_string()));
32069        assert!(operations.contains(&"upsert_edges".to_string()));
32070    }
32071
32072    #[test]
32073    fn context_pack_diff_preview_attaches_tag_ontology_refs() {
32074        let root = tempfile::tempdir().unwrap();
32075        fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
32076        fs::write(
32077            root.path().join(".naming/tags/alpha.md"),
32078            "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
32079        )
32080        .unwrap();
32081        let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
32082        let report = diff_digest::DiffDigestReport {
32083            root: root.path().display().to_string(),
32084            mode: diff_digest::DiffDigestMode::WorkingTree,
32085            revision: None,
32086            files_changed: 1,
32087            files_with_current_summaries: 1,
32088            symbols_touched: 1,
32089            call_edges_added: 0,
32090            call_edges_removed: 0,
32091            files: vec![diff_digest::DiffDigestFile {
32092                path: "src/lib.rs".to_string(),
32093                status: diff_digest::DiffDigestFileStatus::Modified,
32094                touched_symbols: vec!["alpha_helper".to_string()],
32095                summary_state: diff_digest::DiffDigestSummaryState::Current,
32096                current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
32097                    symbol: "alpha_helper".to_string(),
32098                    summary: "alpha helper summary".to_string(),
32099                }],
32100                added_call_edges: vec![],
32101                removed_call_edges: vec![],
32102                warnings: vec![],
32103            }],
32104        };
32105
32106        let preview = build_context_pack_diff_preview(
32107            &report,
32108            ResponseBudget::new(Some(1), Some(80)),
32109            Some(&ontology),
32110        );
32111
32112        let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
32113        assert!(symbol_ref.handle.starts_with("tont-"));
32114        assert_eq!(symbol_ref.tag, "alpha");
32115        assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
32116        assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
32117        assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
32118        assert_eq!(
32119            preview.files[0].summary_refs[0].ontology_refs[0].path,
32120            ".naming/tags/alpha.md"
32121        );
32122    }
32123
32124    #[test]
32125    fn context_pack_test_preview_limits_failure_groups() {
32126        let report = test_digest::TestDigestReport {
32127            root: "/repo".to_string(),
32128            runner: "cargo".to_string(),
32129            failures: 2,
32130            grouped_failures: 2,
32131            counts: test_digest::TestDigestCounts {
32132                passed: Some(8),
32133                failed: Some(2),
32134                skipped: Some(1),
32135            },
32136            failure_groups: vec![
32137                test_digest::TestDigestFailure {
32138                    tests: vec!["suite::alpha_failure".to_string()],
32139                    message: "assertion failed".to_string(),
32140                    path: Some("src/lib.rs".to_string()),
32141                    line: Some(42),
32142                    column: None,
32143                    occurrences: 1,
32144                    summary_state: test_digest::TestDigestSummaryState::Current,
32145                    current_summaries: vec![test_digest::TestDigestSummarySnippet {
32146                        symbol: "alpha_failure".to_string(),
32147                        summary: "failure summary for alpha test".to_string(),
32148                    }],
32149                },
32150                test_digest::TestDigestFailure {
32151                    tests: vec!["suite::beta_failure".to_string()],
32152                    message: "panic".to_string(),
32153                    path: Some("src/main.rs".to_string()),
32154                    line: Some(7),
32155                    column: None,
32156                    occurrences: 1,
32157                    summary_state: test_digest::TestDigestSummaryState::Missing,
32158                    current_summaries: vec![],
32159                },
32160            ],
32161            warnings: vec!["warning text".to_string()],
32162        };
32163
32164        let preview =
32165            build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32166
32167        assert!(preview.truncated);
32168        assert_eq!(preview.failure_groups.len(), 1);
32169        assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
32170        assert_eq!(preview.failure_groups[0].message, "assertion f...");
32171        assert!(
32172            preview.failure_groups[0].summary_refs[0]
32173                .handle
32174                .starts_with("ctsum-")
32175        );
32176        assert_eq!(
32177            preview.failure_groups[0].summary_refs[0].expand,
32178            "tsift summarize --file \"src/lib.rs\""
32179        );
32180        assert_eq!(preview.warnings, vec!["warning text"]);
32181    }
32182
32183    #[test]
32184    fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
32185        let dir = tempfile::tempdir().unwrap();
32186        let root = dir.path();
32187
32188        // Small log: no artifact attached, nothing written.
32189        let small_input = "Compiling serde v1.0.130\n";
32190        let mut small = log_digest::compute(root, small_input).unwrap();
32191        maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
32192        assert!(small.raw_log_artifact.is_none());
32193        assert!(!root.join(".tsift/artifacts").exists());
32194
32195        // Bulky log: artifact persisted with a stable handle and expand command.
32196        let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
32197        let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
32198        maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
32199        let artifact = bulky
32200            .raw_log_artifact
32201            .expect("artifact attached for bulky log");
32202        assert!(artifact.handle.starts_with("logdg-"));
32203        assert_eq!(artifact.bytes, bulky_input.len());
32204        assert!(artifact.expand.contains("tsift log-digest"));
32205        assert!(artifact.expand.contains("--input"));
32206        let persisted = root.join(&artifact.path);
32207        assert!(persisted.exists(), "artifact file written to {persisted:?}");
32208        assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
32209    }
32210
32211    #[test]
32212    fn context_pack_log_preview_limits_signals_and_refs() {
32213        let report = log_digest::LogDigestReport {
32214            root: "/repo".to_string(),
32215            total_lines: 12,
32216            non_empty_lines: 10,
32217            signal_groups: 2,
32218            error_signal_groups: 1,
32219            repeated_line_groups: 2,
32220            repeated_line_occurrences: 3,
32221            line_family_groups: 0,
32222            file_ref_groups: 2,
32223            symbol_ref_groups: 2,
32224            stack_groups: 1,
32225            signals: vec![
32226                log_digest::LogDigestSignal {
32227                    severity: "error".to_string(),
32228                    message: "src/lib.rs:42 boom".to_string(),
32229                    path: Some("src/lib.rs".to_string()),
32230                    line: Some(42),
32231                    column: None,
32232                    occurrences: 2,
32233                    summary_state: log_digest::LogDigestSummaryState::Current,
32234                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32235                        symbol: "alpha_helper".to_string(),
32236                        summary: "alpha helper cached log summary".to_string(),
32237                    }],
32238                },
32239                log_digest::LogDigestSignal {
32240                    severity: "warn".to_string(),
32241                    message: "slow path".to_string(),
32242                    path: None,
32243                    line: None,
32244                    column: None,
32245                    occurrences: 1,
32246                    summary_state: log_digest::LogDigestSummaryState::Unavailable,
32247                    current_summaries: vec![],
32248                },
32249            ],
32250            repeated_lines: vec![
32251                log_digest::LogDigestRepeatedLine {
32252                    line: "retrying work item alpha".to_string(),
32253                    occurrences: 3,
32254                },
32255                log_digest::LogDigestRepeatedLine {
32256                    line: "retrying work item beta".to_string(),
32257                    occurrences: 2,
32258                },
32259            ],
32260            line_families: vec![],
32261            file_refs: vec![
32262                log_digest::LogDigestFileRef {
32263                    path: "src/lib.rs".to_string(),
32264                    line: Some(42),
32265                    column: None,
32266                    occurrences: 2,
32267                    summary_state: log_digest::LogDigestSummaryState::Current,
32268                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32269                        symbol: "alpha_helper".to_string(),
32270                        summary: "alpha helper cached file summary".to_string(),
32271                    }],
32272                },
32273                log_digest::LogDigestFileRef {
32274                    path: "src/main.rs".to_string(),
32275                    line: Some(7),
32276                    column: None,
32277                    occurrences: 1,
32278                    summary_state: log_digest::LogDigestSummaryState::Missing,
32279                    current_summaries: vec![],
32280                },
32281            ],
32282            symbol_refs: vec![
32283                log_digest::LogDigestSymbolRef {
32284                    symbol: "alpha_helper".to_string(),
32285                    occurrences: 2,
32286                    summary_state: log_digest::LogDigestSummaryState::Current,
32287                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32288                        symbol: "alpha_helper".to_string(),
32289                        summary: "alpha helper cached symbol summary".to_string(),
32290                    }],
32291                },
32292                log_digest::LogDigestSymbolRef {
32293                    symbol: "beta_helper".to_string(),
32294                    occurrences: 1,
32295                    summary_state: log_digest::LogDigestSummaryState::Missing,
32296                    current_summaries: vec![],
32297                },
32298            ],
32299            stack_traces: vec![log_digest::LogDigestStackGroup {
32300                frames: vec!["frame one".to_string()],
32301                occurrences: 1,
32302            }],
32303            raw_log_artifact: None,
32304            warnings: vec!["warning text".to_string()],
32305        };
32306
32307        let preview =
32308            build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32309
32310        assert!(preview.truncated);
32311        assert_eq!(preview.signals.len(), 1);
32312        assert_eq!(preview.signals[0].message, "src/lib.rs:...");
32313        assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
32314        assert_eq!(preview.file_refs.len(), 1);
32315        assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
32316        assert!(
32317            preview.signals[0].summary_refs[0]
32318                .handle
32319                .starts_with("clsum-")
32320        );
32321        assert!(
32322            preview.file_refs[0].summary_refs[0]
32323                .handle
32324                .starts_with("clfsum-")
32325        );
32326        assert!(
32327            preview.symbol_refs[0].summary_refs[0]
32328                .handle
32329                .starts_with("clssum-")
32330        );
32331        assert_eq!(
32332            preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
32333            Some("alpha/helper")
32334        );
32335        assert_eq!(
32336            preview.symbol_refs[0].summary_refs[0].expand,
32337            "tsift summarize \"alpha_helper\""
32338        );
32339        assert_eq!(preview.warnings, vec!["warning text"]);
32340    }
32341
32342    #[test]
32343    fn cli_search_rejects_exact_with_strategy_flag() {
32344        let cli = try_parse_cli([
32345            "tsift",
32346            "search",
32347            "test",
32348            "--exact",
32349            "--strategy",
32350            "lexical",
32351        ]);
32352        assert!(cli.is_err());
32353    }
32354
32355    #[test]
32356    fn cli_search_autoindexes_by_default() {
32357        let cli = parse_cli(["tsift", "search", "test"]);
32358        match cli.command {
32359            Some(Commands::Search {
32360                autoindex,
32361                no_autoindex,
32362                ..
32363            }) => {
32364                assert!(!autoindex);
32365                assert!(!no_autoindex);
32366                assert!(autoindex || !no_autoindex);
32367            }
32368            _ => panic!("expected Search command"),
32369        }
32370    }
32371
32372    #[test]
32373    fn cli_local_model_status_accepts_json_and_no_probe() {
32374        let cli = parse_cli(["tsift", "local-model", "status", "--json", "--no-probe"]);
32375        match cli.command {
32376            Some(Commands::LocalModel {
32377                command: LocalModelCommand::Status { json, no_probe },
32378            }) => {
32379                assert!(json);
32380                assert!(no_probe);
32381            }
32382            _ => panic!("expected LocalModel status command"),
32383        }
32384    }
32385
32386    #[test]
32387    fn cli_local_model_unload_accepts_probe_and_strict_flags() {
32388        let cli = parse_cli([
32389            "tsift",
32390            "local-model",
32391            "unload",
32392            "--profile",
32393            "qwen3-32b-q4",
32394            "--pre-used-mib",
32395            "200",
32396            "--post-used-mib",
32397            "800",
32398            "--provider-pid",
32399            "42",
32400            "--strict",
32401            "--json",
32402        ]);
32403        match cli.command {
32404            Some(Commands::LocalModel {
32405                command:
32406                    LocalModelCommand::Unload {
32407                        profile,
32408                        provider_pid,
32409                        pre_used_mib,
32410                        post_used_mib,
32411                        strict,
32412                        json,
32413                        ..
32414                    },
32415            }) => {
32416                assert_eq!(profile, "qwen3-32b-q4");
32417                assert_eq!(provider_pid, Some(42));
32418                assert_eq!(pre_used_mib, Some(200));
32419                assert_eq!(post_used_mib, Some(800));
32420                assert!(strict);
32421                assert!(json);
32422            }
32423            _ => panic!("expected LocalModel unload command"),
32424        }
32425    }
32426
32427    #[test]
32428    fn cli_local_model_swap_parses_flags() {
32429        let cli = parse_cli([
32430            "tsift",
32431            "local-model",
32432            "swap",
32433            "--from",
32434            "qwen3-32b-q4",
32435            "--to",
32436            "qwen3-embedding-0.6b",
32437            "--provider-pid",
32438            "42",
32439            "--pre-used-mib",
32440            "200",
32441            "--post-used-mib",
32442            "180",
32443            "--strict",
32444            "--json",
32445        ]);
32446        match cli.command {
32447            Some(Commands::LocalModel {
32448                command:
32449                    LocalModelCommand::Swap {
32450                        from,
32451                        to,
32452                        provider_pid,
32453                        pre_used_mib,
32454                        post_used_mib,
32455                        strict,
32456                        json,
32457                        ..
32458                    },
32459            }) => {
32460                assert_eq!(from, "qwen3-32b-q4");
32461                assert_eq!(to, "qwen3-embedding-0.6b");
32462                assert_eq!(provider_pid, Some(42));
32463                assert_eq!(pre_used_mib, Some(200));
32464                assert_eq!(post_used_mib, Some(180));
32465                assert!(strict);
32466                assert!(json);
32467            }
32468            _ => panic!("expected LocalModel swap command"),
32469        }
32470    }
32471
32472    #[test]
32473    fn cli_local_model_resolve_parses_flags() {
32474        use cli::ResolveRole;
32475        let cli = parse_cli([
32476            "tsift",
32477            "local-model",
32478            "resolve",
32479            "--profile",
32480            "hash",
32481            "--role",
32482            "embed",
32483            "--no-probe",
32484            "--json",
32485        ]);
32486        match cli.command {
32487            Some(Commands::LocalModel {
32488                command:
32489                    LocalModelCommand::Resolve {
32490                        profile,
32491                        role,
32492                        no_probe,
32493                        json,
32494                    },
32495            }) => {
32496                assert_eq!(profile.as_deref(), Some("hash"));
32497                assert_eq!(role, ResolveRole::Embed);
32498                assert!(no_probe);
32499                assert!(json);
32500            }
32501            _ => panic!("expected LocalModel resolve command"),
32502        }
32503    }
32504
32505    #[test]
32506    fn cli_semantic_command_accepts_profile_flag() {
32507        let cli = parse_cli([
32508            "tsift",
32509            "semantic",
32510            "auth",
32511            "--profile",
32512            "qwen3-embedding-0.6b",
32513            "--json",
32514        ]);
32515        match cli.command {
32516            Some(Commands::Semantic { profile, query, .. }) => {
32517                assert_eq!(query, "auth");
32518                assert_eq!(profile.as_deref(), Some("qwen3-embedding-0.6b"));
32519            }
32520            _ => panic!("expected Semantic command"),
32521        }
32522    }
32523
32524    #[test]
32525    fn cli_summarize_command_accepts_profile_flag() {
32526        let cli = parse_cli([
32527            "tsift",
32528            "summarize",
32529            "--extract",
32530            "src",
32531            "--profile",
32532            "hash",
32533            "--json",
32534        ]);
32535        match cli.command {
32536            Some(Commands::Summarize {
32537                extract,
32538                profile,
32539                json,
32540                ..
32541            }) => {
32542                assert_eq!(extract.as_deref(), Some(std::path::Path::new("src")));
32543                assert_eq!(profile.as_deref(), Some("hash"));
32544                assert!(json);
32545            }
32546            _ => panic!("expected Summarize command"),
32547        }
32548    }
32549
32550    #[test]
32551    fn cli_local_model_lease_acquire_parses_flags() {
32552        let cli = parse_cli([
32553            "tsift",
32554            "local-model",
32555            "lease",
32556            "acquire",
32557            "--profile",
32558            "qwen3-32b-q4",
32559            "--holder-pid",
32560            "4242",
32561            "--holder-command",
32562            "corky",
32563            "--idle-ttl-seconds",
32564            "120",
32565            "--vram-baseline-mib",
32566            "200",
32567            "--lease-file",
32568            "/tmp/tsift-lease.json",
32569            "--strict",
32570            "--json",
32571        ]);
32572        match cli.command {
32573            Some(Commands::LocalModel {
32574                command:
32575                    LocalModelCommand::Lease {
32576                        command:
32577                            LeaseCommand::Acquire {
32578                                profile,
32579                                holder_pid,
32580                                holder_command,
32581                                idle_ttl_seconds,
32582                                vram_baseline_mib,
32583                                lease_file,
32584                                strict,
32585                                json,
32586                                ..
32587                            },
32588                    },
32589            }) => {
32590                assert_eq!(profile, "qwen3-32b-q4");
32591                assert_eq!(holder_pid, Some(4242));
32592                assert_eq!(holder_command, "corky");
32593                assert_eq!(idle_ttl_seconds, 120);
32594                assert_eq!(vram_baseline_mib, Some(200));
32595                assert_eq!(lease_file, Some(PathBuf::from("/tmp/tsift-lease.json")));
32596                assert!(strict);
32597                assert!(json);
32598            }
32599            _ => panic!("expected LocalModel lease acquire command"),
32600        }
32601    }
32602
32603    #[test]
32604    fn cli_local_model_lease_release_parses_flags() {
32605        let cli = parse_cli([
32606            "tsift",
32607            "local-model",
32608            "lease",
32609            "release",
32610            "--profile",
32611            "qwen3-embedding-0.6b",
32612            "--holder-pid",
32613            "999",
32614            "--json",
32615        ]);
32616        match cli.command {
32617            Some(Commands::LocalModel {
32618                command:
32619                    LocalModelCommand::Lease {
32620                        command:
32621                            LeaseCommand::Release {
32622                                profile,
32623                                holder_pid,
32624                                json,
32625                                ..
32626                            },
32627                    },
32628            }) => {
32629                assert_eq!(profile, "qwen3-embedding-0.6b");
32630                assert_eq!(holder_pid, Some(999));
32631                assert!(json);
32632            }
32633            _ => panic!("expected LocalModel lease release command"),
32634        }
32635    }
32636
32637    #[test]
32638    fn cli_local_model_lease_show_parses_flags() {
32639        let cli = parse_cli([
32640            "tsift",
32641            "local-model",
32642            "lease",
32643            "show",
32644            "--include-stale",
32645            "--json",
32646        ]);
32647        match cli.command {
32648            Some(Commands::LocalModel {
32649                command:
32650                    LocalModelCommand::Lease {
32651                        command:
32652                            LeaseCommand::Show {
32653                                include_stale,
32654                                json,
32655                                ..
32656                            },
32657                    },
32658            }) => {
32659                assert!(include_stale);
32660                assert!(json);
32661            }
32662            _ => panic!("expected LocalModel lease show command"),
32663        }
32664    }
32665
32666    #[test]
32667    fn cli_search_accepts_no_autoindex_flag() {
32668        let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
32669        match cli.command {
32670            Some(Commands::Search {
32671                autoindex,
32672                no_autoindex,
32673                ..
32674            }) => {
32675                assert!(!autoindex);
32676                assert!(no_autoindex);
32677            }
32678            _ => panic!("expected Search command"),
32679        }
32680    }
32681
32682    #[test]
32683    fn cli_search_rejects_conflicting_autoindex_flags() {
32684        let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
32685        assert!(cli.is_err());
32686    }
32687
32688    // --- relativize paths ---
32689
32690    #[test]
32691    fn cli_accepts_global_absolute_flag() {
32692        let cli = parse_cli(["tsift", "--absolute", "status"]);
32693        assert!(cli.absolute);
32694        assert!(matches!(cli.command, Some(Commands::Status { .. })));
32695    }
32696
32697    #[test]
32698    fn cli_accepts_global_tabular_flag() {
32699        let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
32700        assert!(cli.tabular);
32701        assert!(matches!(cli.command, Some(Commands::Search { .. })));
32702    }
32703
32704    #[test]
32705    fn cli_tabular_with_graph() {
32706        let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
32707        assert!(cli.tabular);
32708        assert!(matches!(cli.command, Some(Commands::Graph { .. })));
32709    }
32710
32711    #[test]
32712    fn cli_tabular_with_communities() {
32713        let cli = parse_cli(["tsift", "--tabular", "communities"]);
32714        assert!(cli.tabular);
32715        assert!(matches!(cli.command, Some(Commands::Communities { .. })));
32716    }
32717
32718    #[test]
32719    fn cli_tabular_with_explain() {
32720        let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
32721        assert!(cli.tabular);
32722        assert!(matches!(cli.command, Some(Commands::Explain { .. })));
32723    }
32724
32725    #[test]
32726    fn cli_traverse_accepts_path_target_and_html_format() {
32727        let cli = parse_cli([
32728            "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
32729        ]);
32730        match cli.command {
32731            Some(Commands::Traverse {
32732                node,
32733                to,
32734                path,
32735                format,
32736                ..
32737            }) => {
32738                assert_eq!(node.as_deref(), Some("#kgnv"));
32739                assert_eq!(to.as_deref(), Some("main"));
32740                assert_eq!(path, PathBuf::from("."));
32741                assert_eq!(format, TraverseFormat::Html);
32742            }
32743            _ => panic!("expected Traverse command"),
32744        }
32745    }
32746
32747    #[test]
32748    fn cli_parses_semantic_related_command() {
32749        let cli = parse_cli([
32750            "tsift",
32751            "semantic",
32752            "graph navigation",
32753            "--path",
32754            ".",
32755            "--kind",
32756            "all",
32757            "--limit",
32758            "3",
32759            "--json",
32760        ]);
32761        match cli.command {
32762            Some(Commands::Semantic {
32763                query,
32764                path,
32765                kind,
32766                limit,
32767                json,
32768                ..
32769            }) => {
32770                assert_eq!(query, "graph navigation");
32771                assert_eq!(path, PathBuf::from("."));
32772                assert_eq!(kind, SemanticRelatedKind::All);
32773                assert_eq!(limit, 3);
32774                assert!(json);
32775            }
32776            _ => panic!("expected Semantic command"),
32777        }
32778    }
32779
32780    #[test]
32781    fn cli_parses_convex_sync_command() {
32782        let cli = parse_cli([
32783            "tsift",
32784            "convex-sync",
32785            ".",
32786            "--snapshot",
32787            "rows.json",
32788            "--chunk-size",
32789            "25",
32790            "--json",
32791        ]);
32792        match cli.command {
32793            Some(Commands::ConvexSync {
32794                path,
32795                snapshot,
32796                chunk_size,
32797                json,
32798                ..
32799            }) => {
32800                assert_eq!(path, PathBuf::from("."));
32801                assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
32802                assert_eq!(chunk_size, 25);
32803                assert!(json);
32804            }
32805            _ => panic!("expected ConvexSync command"),
32806        }
32807    }
32808
32809    #[test]
32810    fn cli_parses_convex_sync_live_flags() {
32811        let cli = parse_cli([
32812            "tsift",
32813            "convex-sync",
32814            ".",
32815            "--remote-snapshot",
32816            "--apply",
32817            "--endpoint",
32818            "https://example.test/convex-graph",
32819            "--auth-token-env",
32820            "TSIFT_TEST_TOKEN",
32821        ]);
32822        match cli.command {
32823            Some(Commands::ConvexSync {
32824                remote_snapshot,
32825                apply,
32826                endpoint,
32827                auth_token_env,
32828                ..
32829            }) => {
32830                assert!(remote_snapshot);
32831                assert!(apply);
32832                assert_eq!(
32833                    endpoint.as_deref(),
32834                    Some("https://example.test/convex-graph")
32835                );
32836                assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
32837            }
32838            _ => panic!("expected ConvexSync command"),
32839        }
32840    }
32841
32842    #[test]
32843    fn cli_parses_graph_db_query() {
32844        let cli = parse_cli([
32845            "tsift",
32846            "graph-db",
32847            "--backend",
32848            "convex-snapshot",
32849            "--convex-snapshot",
32850            "rows.json",
32851            "--json",
32852            "neighborhood",
32853            "gbak-kgnv",
32854            "--depth",
32855            "2",
32856            "--edge-kind",
32857            "mentions",
32858            "--property",
32859            "path=tasks/software/tsift.md",
32860            "--cursor",
32861            "gbak-old",
32862            "--limit",
32863            "10",
32864        ]);
32865        match cli.command {
32866            Some(Commands::GraphDb {
32867                backend,
32868                convex_snapshot,
32869                json,
32870                query,
32871                ..
32872            }) => {
32873                assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
32874                assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
32875                assert!(json);
32876                match query {
32877                    GraphDbQuery::Neighborhood {
32878                        id,
32879                        depth,
32880                        edge_kind,
32881                        cursor,
32882                        limit,
32883                        property_filters,
32884                    } => {
32885                        assert_eq!(id, "gbak-kgnv");
32886                        assert_eq!(depth, 2);
32887                        assert_eq!(edge_kind.as_deref(), Some("mentions"));
32888                        assert_eq!(cursor.as_deref(), Some("gbak-old"));
32889                        assert_eq!(limit, Some(10));
32890                        assert_eq!(
32891                            property_filters,
32892                            vec!["path=tasks/software/tsift.md".to_string()]
32893                        );
32894                    }
32895                    _ => panic!("expected graph-db neighborhood query"),
32896                }
32897            }
32898            _ => panic!("expected GraphDb command"),
32899        }
32900    }
32901
32902    #[test]
32903    fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
32904        let cli = parse_cli([
32905            "tsift",
32906            "graph-db",
32907            "--json",
32908            "backend-eval",
32909            "--candidate",
32910            "surrealdb",
32911            "--target",
32912            "gval",
32913            "--full-projection",
32914        ]);
32915        match cli.command {
32916            Some(Commands::GraphDb { json, query, .. }) => {
32917                assert!(json);
32918                match query {
32919                    GraphDbQuery::BackendEval {
32920                        candidates,
32921                        targets,
32922                        full_projection,
32923                    } => {
32924                        assert_eq!(candidates, vec!["surrealdb".to_string()]);
32925                        assert_eq!(targets, vec!["gval".to_string()]);
32926                        assert!(full_projection);
32927                    }
32928                    _ => panic!("expected graph-db backend-eval query"),
32929                }
32930            }
32931            _ => panic!("expected GraphDb command"),
32932        }
32933    }
32934
32935    #[test]
32936    fn cli_parses_graph_db_tokensave_backend() {
32937        let cli = parse_cli([
32938            "tsift",
32939            "graph-db",
32940            "--backend",
32941            "tokensave",
32942            "--json",
32943            "node",
32944            "fn:main",
32945        ]);
32946        match cli.command {
32947            Some(Commands::GraphDb {
32948                backend,
32949                json,
32950                query,
32951                ..
32952            }) => {
32953                assert_eq!(backend, GraphDbBackend::Tokensave);
32954                assert!(json);
32955                match query {
32956                    GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
32957                    _ => panic!("expected graph-db node query"),
32958                }
32959            }
32960            _ => panic!("expected GraphDb command"),
32961        }
32962    }
32963
32964    #[test]
32965    fn cli_parses_analyze_command() {
32966        let cli = parse_cli([
32967            "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
32968            "--limit", "7", "--json",
32969        ]);
32970        match cli.command {
32971            Some(Commands::Analyze {
32972                path,
32973                scope,
32974                entry_points,
32975                limit,
32976                json,
32977            }) => {
32978                assert_eq!(path, PathBuf::from("."));
32979                assert_eq!(scope.as_deref(), Some("core"));
32980                assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
32981                assert_eq!(limit, 7);
32982                assert!(json);
32983            }
32984            _ => panic!("expected Analyze command"),
32985        }
32986    }
32987
32988    #[test]
32989    fn cli_parses_graph_db_related_query() {
32990        let cli = parse_cli([
32991            "tsift",
32992            "graph-db",
32993            "--json",
32994            "related",
32995            "voice avatar memory retrieval",
32996            "--kind",
32997            "all",
32998            "--depth",
32999            "3",
33000            "--seed-limit",
33001            "4",
33002            "--limit",
33003            "12",
33004        ]);
33005        match cli.command {
33006            Some(Commands::GraphDb { json, query, .. }) => {
33007                assert!(json);
33008                match query {
33009                    GraphDbQuery::Related {
33010                        query,
33011                        kind,
33012                        depth,
33013                        seed_limit,
33014                        limit,
33015                    } => {
33016                        assert_eq!(query, "voice avatar memory retrieval");
33017                        assert_eq!(kind, SemanticRelatedKind::All);
33018                        assert_eq!(depth, 3);
33019                        assert_eq!(seed_limit, 4);
33020                        assert_eq!(limit, 12);
33021                    }
33022                    _ => panic!("expected graph-db related query"),
33023                }
33024            }
33025            _ => panic!("expected GraphDb command"),
33026        }
33027    }
33028
33029    #[test]
33030    fn cli_parses_graph_db_compact_query() {
33031        let cli = parse_cli([
33032            "tsift",
33033            "graph-db",
33034            "--path",
33035            ".",
33036            "compact",
33037            "--apply",
33038            "--prune-tombstones",
33039            "--confirmed-convex-reconciled",
33040        ]);
33041        match cli.command {
33042            Some(Commands::GraphDb { query, .. }) => match query {
33043                GraphDbQuery::Compact {
33044                    apply,
33045                    prune_tombstones,
33046                    confirmed_convex_reconciled,
33047                } => {
33048                    assert!(apply);
33049                    assert!(prune_tombstones);
33050                    assert!(confirmed_convex_reconciled);
33051                }
33052                _ => panic!("expected graph-db compact query"),
33053            },
33054            _ => panic!("expected GraphDb command"),
33055        }
33056    }
33057
33058    #[test]
33059    fn cli_parses_graph_db_snapshot_queries() {
33060        let export_cli = parse_cli([
33061            "tsift",
33062            "graph-db",
33063            "--json",
33064            "snapshot-export",
33065            "graph.db.gz",
33066            "--force",
33067        ]);
33068        match export_cli.command {
33069            Some(Commands::GraphDb { json, query, .. }) => {
33070                assert!(json);
33071                match query {
33072                    GraphDbQuery::SnapshotExport { output, force } => {
33073                        assert_eq!(output, PathBuf::from("graph.db.gz"));
33074                        assert!(force);
33075                    }
33076                    _ => panic!("expected graph-db snapshot-export query"),
33077                }
33078            }
33079            _ => panic!("expected GraphDb command"),
33080        }
33081
33082        let import_cli = parse_cli([
33083            "tsift",
33084            "graph-db",
33085            "snapshot-import",
33086            "graph.db.gz",
33087            "--replace",
33088        ]);
33089        match import_cli.command {
33090            Some(Commands::GraphDb { query, .. }) => match query {
33091                GraphDbQuery::SnapshotImport { artifact, replace } => {
33092                    assert_eq!(artifact, PathBuf::from("graph.db.gz"));
33093                    assert!(replace);
33094                }
33095                _ => panic!("expected graph-db snapshot-import query"),
33096            },
33097            _ => panic!("expected GraphDb command"),
33098        }
33099    }
33100
33101    #[test]
33102    fn cli_parses_impact_command() {
33103        let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
33104        match cli.command {
33105            Some(Commands::Impact {
33106                path,
33107                cached,
33108                limit,
33109                ..
33110            }) => {
33111                assert_eq!(path, PathBuf::from("."));
33112                assert!(cached);
33113                assert_eq!(limit, 5);
33114            }
33115            _ => panic!("expected Impact command"),
33116        }
33117    }
33118
33119    #[test]
33120    fn cli_parses_conflict_matrix_command() {
33121        let cli = parse_cli([
33122            "tsift",
33123            "conflict-matrix",
33124            "--path",
33125            "tasks/software/tsift.md",
33126            "--depth",
33127            "4",
33128            "--limit",
33129            "12",
33130            "--impact-limit",
33131            "6",
33132            "--json",
33133            "pwcm",
33134            "#g6kf",
33135        ]);
33136        match cli.command {
33137            Some(Commands::ConflictMatrix {
33138                targets,
33139                path,
33140                depth,
33141                limit,
33142                impact_limit,
33143                json,
33144                ..
33145            }) => {
33146                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33147                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33148                assert_eq!(depth, 4);
33149                assert_eq!(limit, 12);
33150                assert_eq!(impact_limit, 6);
33151                assert!(json);
33152            }
33153            _ => panic!("expected ConflictMatrix command"),
33154        }
33155    }
33156
33157    #[test]
33158    fn cli_parses_dispatch_trace_command() {
33159        let cli = parse_cli([
33160            "tsift",
33161            "dispatch-trace",
33162            "--path",
33163            "tasks/software/tsift.md",
33164            "--format",
33165            "html",
33166            "--depth",
33167            "4",
33168            "pwcm",
33169            "#g6kf",
33170        ]);
33171        match cli.command {
33172            Some(Commands::DispatchTrace {
33173                targets,
33174                path,
33175                format,
33176                depth,
33177                ..
33178            }) => {
33179                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33180                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33181                assert_eq!(format, DispatchTraceFormat::Html);
33182                assert_eq!(depth, 4);
33183            }
33184            _ => panic!("expected DispatchTrace command"),
33185        }
33186    }
33187
33188    #[test]
33189    fn cli_parses_dependency_dag_command() {
33190        let cli = parse_cli([
33191            "tsift",
33192            "dependency-dag",
33193            "--path",
33194            "tasks/software/tsift.md",
33195            "--depth",
33196            "5",
33197            "--limit",
33198            "20",
33199            "--json",
33200            "alpha",
33201            "#beta",
33202        ]);
33203        match cli.command {
33204            Some(Commands::DependencyDag {
33205                targets,
33206                path,
33207                depth,
33208                limit,
33209                json,
33210                ..
33211            }) => {
33212                assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
33213                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33214                assert_eq!(depth, 5);
33215                assert_eq!(limit, 20);
33216                assert!(json);
33217            }
33218            _ => panic!("expected DependencyDag command"),
33219        }
33220    }
33221
33222    #[test]
33223    fn relativize_strips_root_prefix() {
33224        let root = std::path::Path::new("/home/user/project");
33225        assert_eq!(
33226            relativize("/home/user/project/src/main.rs", root),
33227            "src/main.rs"
33228        );
33229    }
33230
33231    #[test]
33232    fn relativize_leaves_non_matching_path() {
33233        let root = std::path::Path::new("/home/user/project");
33234        assert_eq!(
33235            relativize("/other/path/file.rs", root),
33236            "/other/path/file.rs"
33237        );
33238    }
33239
33240    #[test]
33241    fn relativize_leaves_already_relative() {
33242        let root = std::path::Path::new("/home/user/project");
33243        assert_eq!(relativize("src/main.rs", root), "src/main.rs");
33244    }
33245
33246    #[test]
33247    fn relativize_pathbuf_strips_prefix() {
33248        let root = std::path::Path::new("/home/user/project");
33249        let path = std::path::Path::new("/home/user/project/src/lib.rs");
33250        assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
33251    }
33252
33253    #[test]
33254    fn relativize_edges_strips_caller_file() {
33255        let root = std::path::Path::new("/tmp/proj");
33256        let mut edges = vec![index::StoredEdge {
33257            caller_file: "/tmp/proj/src/main.rs".to_string(),
33258            caller_name: "main".to_string(),
33259            caller_line: 1,
33260            callee_name: "helper".to_string(),
33261            call_site_line: 5,
33262            tagpath_handle: None,
33263        }];
33264        relativize_edges(&mut edges, root);
33265        assert_eq!(edges[0].caller_file, "src/main.rs");
33266    }
33267
33268    #[test]
33269    fn relativize_json_paths_strips_known_keys() {
33270        let root = std::path::Path::new("/tmp/proj");
33271        let mut val = serde_json::json!({
33272            "file": "/tmp/proj/src/main.rs",
33273            "path": "/tmp/proj/test.rs",
33274            "name": "/tmp/proj/not-a-path",
33275            "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
33276        });
33277        relativize_json_paths(&mut val, root);
33278        assert_eq!(val["file"], "src/main.rs");
33279        assert_eq!(val["path"], "test.rs");
33280        assert_eq!(val["name"], "/tmp/proj/not-a-path");
33281        assert_eq!(val["hits"][0]["path"], "nested.rs");
33282    }
33283
33284    // --- limit caps ---
33285
33286    #[test]
33287    fn cli_graph_accepts_limit_flag() {
33288        let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
33289        match cli.command {
33290            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
33291            _ => panic!("expected Graph command"),
33292        }
33293    }
33294
33295    #[test]
33296    fn cli_graph_default_limit_is_20() {
33297        let cli = parse_cli(["tsift", "graph", "main"]);
33298        match cli.command {
33299            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
33300            _ => panic!("expected Graph command"),
33301        }
33302    }
33303
33304    #[test]
33305    fn cli_communities_accepts_limit_flag() {
33306        let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
33307        match cli.command {
33308            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
33309            _ => panic!("expected Communities command"),
33310        }
33311    }
33312
33313    #[test]
33314    fn cli_communities_default_limit_is_10() {
33315        let cli = parse_cli(["tsift", "communities"]);
33316        match cli.command {
33317            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
33318            _ => panic!("expected Communities command"),
33319        }
33320    }
33321
33322    #[test]
33323    fn cli_explain_accepts_limit_flag() {
33324        let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
33325        match cli.command {
33326            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
33327            _ => panic!("expected Explain command"),
33328        }
33329    }
33330
33331    #[test]
33332    fn cli_explain_default_limit_is_15() {
33333        let cli = parse_cli(["tsift", "explain", "main"]);
33334        match cli.command {
33335            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
33336            _ => panic!("expected Explain command"),
33337        }
33338    }
33339
33340    #[test]
33341    fn cli_limit_zero_means_unlimited() {
33342        let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
33343        match cli.command {
33344            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
33345            _ => panic!("expected Graph command"),
33346        }
33347    }
33348
33349    #[test]
33350    fn graph_cmd_limit_runs_ok() {
33351        let dir = setup_graph_index();
33352        let result = cmd_graph(
33353            "main",
33354            dir.path(),
33355            false,
33356            false,
33357            None,
33358            1,
33359            false,
33360            false,
33361            false,
33362            false,
33363            false,
33364            false,
33365            false,
33366            TagpathSearchOpts::default(),
33367        );
33368        assert!(result.is_ok());
33369    }
33370
33371    #[test]
33372    fn graph_cmd_unlimited_runs_ok() {
33373        let dir = setup_graph_index();
33374        let result = cmd_graph(
33375            "main",
33376            dir.path(),
33377            false,
33378            false,
33379            None,
33380            0,
33381            false,
33382            false,
33383            false,
33384            false,
33385            false,
33386            false,
33387            false,
33388            TagpathSearchOpts::default(),
33389        );
33390        assert!(result.is_ok());
33391    }
33392
33393    #[test]
33394    fn graph_cmd_tabular_runs_ok() {
33395        let dir = setup_graph_index();
33396        let result = cmd_graph(
33397            "main",
33398            dir.path(),
33399            false,
33400            false,
33401            None,
33402            20,
33403            false,
33404            false,
33405            false,
33406            false,
33407            false,
33408            true,
33409            false,
33410            TagpathSearchOpts::default(),
33411        );
33412        assert!(result.is_ok());
33413    }
33414
33415    #[test]
33416    fn communities_cmd_tabular_runs_ok() {
33417        let dir = setup_graph_index();
33418        let result = cmd_communities(
33419            dir.path(),
33420            None,
33421            1,
33422            10,
33423            false,
33424            false,
33425            false,
33426            false,
33427            true,
33428            false,
33429            TagpathSearchOpts::default(),
33430        );
33431        assert!(result.is_ok());
33432    }
33433
33434    #[test]
33435    fn explain_cmd_tabular_runs_ok() {
33436        let dir = setup_graph_index();
33437        let result = cmd_explain(
33438            "main",
33439            dir.path(),
33440            None,
33441            15,
33442            false,
33443            false,
33444            false,
33445            false,
33446            false,
33447            true,
33448            false,
33449            false,
33450        );
33451        assert!(result.is_ok());
33452    }
33453
33454    #[test]
33455    fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
33456        // #gdbcacheprove: .agent-doc runtime markdown (snapshots, baselines, archives,
33457        // session docs, runtime logs) must not contribute to the source watermark, or
33458        // every agent-doc cycle would invalidate the graph-db backend-eval cache and
33459        // force a full rebuild on the next run.
33460        let cases = [
33461            ".agent-doc",
33462            ".agent-doc/snapshots/abc.md",
33463            ".agent-doc/baselines/abc.md",
33464            ".agent-doc/archives/2026.md",
33465            ".agent-doc/runtime/run.jsonl",
33466            "src/foo/.agent-doc",
33467            "src/foo/.agent-doc/snapshots/x.md",
33468            "./.agent-doc/snapshots/x.md",
33469        ];
33470        for path in cases {
33471            assert!(
33472                traversal_relative_path_is_generated_artifact(path),
33473                "expected `{path}` to be excluded from source watermark"
33474            );
33475        }
33476        // Real source paths must NOT be excluded.
33477        for path in [
33478            "src/main.rs",
33479            "tests/perf_gate.rs",
33480            "fixtures/x.json",
33481            "agent-doc/src/lib.rs", // sibling dir without the leading dot
33482            "src/.agent-doc-helper.rs",
33483        ] {
33484            assert!(
33485                !traversal_relative_path_is_generated_artifact(path),
33486                "expected `{path}` to be included in source watermark"
33487            );
33488        }
33489    }
33490
33491    #[test]
33492    fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
33493        // #cachelookupshift: the conflict-matrix preparation cache key hashes
33494        // file_state snapshot rows + every markdown file under the root. Any
33495        // .tsift/, target/, or .agent-doc/ path slipping past the filter would
33496        // shift the watermark every run because those directories mutate as a
33497        // side effect of running tsift itself. This test locks the artifact
33498        // filter against regressions for each prefix variant
33499        // (bare, root-anchored, nested, and './' leading).
33500        let cases = [
33501            ".tsift",
33502            ".tsift/index.db",
33503            ".tsift/indexes/foo/index.db",
33504            ".tsift/conflict-matrix-cache/inputs/abc.json",
33505            ".tsift/summaries.db",
33506            "src/foo/.tsift",
33507            "src/foo/.tsift/graph.db",
33508            "./.tsift/index.db",
33509            "target",
33510            "target/debug/build/x",
33511            "target/release/tsift",
33512            "src/foo/target/debug/x",
33513            "./target/release/x",
33514        ];
33515        for path in cases {
33516            assert!(
33517                traversal_relative_path_is_generated_artifact(path),
33518                "expected `{path}` to be excluded from source watermark"
33519            );
33520        }
33521        // Look-alike paths must NOT be excluded — only true artifact dirs.
33522        for path in [
33523            "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
33524            "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
33525            "src/tsift-extras/lib.rs",
33526            "tsift/README.md",
33527            "src/targeting.rs",
33528            "src/.tsiftrc",
33529            "src/agent-doc-helper.rs",
33530        ] {
33531            assert!(
33532                !traversal_relative_path_is_generated_artifact(path),
33533                "expected `{path}` to be included in source watermark"
33534            );
33535        }
33536    }
33537
33538    #[test]
33539    fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
33540        // #cachelookupshift: the conflict-matrix preparation cache only hits
33541        // when traversal_source_watermark returns the same hash for two
33542        // consecutive calls on identical source state. Lock that invariant so
33543        // a future change that folds wall-clock time, a directory mtime, or
33544        // any other non-content input into the hash trips this test before
33545        // regressing the preparation_cache_lookup hit rate. We exercise the
33546        // session_only=true path with a hinted markdown file so the test does
33547        // not need a full index DB to drive the index-snapshot branch.
33548        let dir = tempfile::tempdir().unwrap();
33549        let root = dir.path();
33550        std::fs::create_dir_all(root.join("src")).unwrap();
33551        std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
33552        let hint = root.join("README.md");
33553        std::fs::write(&hint, "# stable\n").unwrap();
33554        // Add a generated-artifact directory that must NOT affect the watermark.
33555        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33556        std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
33557        std::fs::create_dir_all(root.join("target/debug")).unwrap();
33558        std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
33559
33560        let first = traversal_source_watermark(root, &hint, None, true)
33561            .expect("first watermark call must succeed")
33562            .expect("first watermark must produce a hash for hinted markdown");
33563        let second = traversal_source_watermark(root, &hint, None, true)
33564            .expect("second watermark call must succeed")
33565            .expect("second watermark must produce a hash for hinted markdown");
33566        assert_eq!(
33567            first, second,
33568            "watermark must be identical across back-to-back invocations on a quiescent root"
33569        );
33570
33571        // Mutating a generated-artifact file must NOT shift the hash.
33572        std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
33573        std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
33574        let third = traversal_source_watermark(root, &hint, None, true)
33575            .expect("third watermark call must succeed")
33576            .expect("third watermark must produce a hash for hinted markdown");
33577        assert_eq!(
33578            first, third,
33579            "watermark must ignore mutations under .tsift/ and target/"
33580        );
33581
33582        // Mutating the hinted markdown file MUST shift the hash so the
33583        // preparation cache invalidates correctly when user state changes.
33584        // Sleep briefly to push the file mtime past the original even on
33585        // coarse-resolution filesystems.
33586        std::thread::sleep(std::time::Duration::from_millis(20));
33587        std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
33588        let fourth = traversal_source_watermark(root, &hint, None, true)
33589            .expect("fourth watermark call must succeed")
33590            .expect("fourth watermark must produce a hash for hinted markdown");
33591        assert_ne!(
33592            first, fourth,
33593            "watermark must invalidate when the hinted markdown file changes"
33594        );
33595    }
33596
33597    #[test]
33598    fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
33599        // #gcachemiss: full-projection cache keys must not miss just because the
33600        // SQLite summary cache file header or mtime churned. Only the semantic rows
33601        // that feed traversal projection should participate in the source watermark.
33602        let dir = tempfile::tempdir().unwrap();
33603        let root = dir.path();
33604        std::fs::write(root.join("README.md"), "# stable\n").unwrap();
33605        let summaries_db_path = root.join(".tsift/summaries.db");
33606        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33607        let mut summary = summarize::Summary {
33608            id: 0,
33609            symbol_name: "main".to_string(),
33610            file_path: "src/main.rs".to_string(),
33611            content_hash: "hash-main".to_string(),
33612            summary: "main wires the CLI".to_string(),
33613            entities: Some(vec![summarize::Entity {
33614                name: "Cli".to_string(),
33615                kind: "type".to_string(),
33616                description: "Command-line interface".to_string(),
33617            }]),
33618            relationships: None,
33619            concept_labels: Some(vec!["cli".to_string()]),
33620            extracted_at: "1700000000".to_string(),
33621            model: "test-model".to_string(),
33622            tokens_input: Some(10),
33623            tokens_output: Some(5),
33624        };
33625        summary_db.insert(&summary).unwrap();
33626        drop(summary_db);
33627
33628        let hint = root.join("README.md");
33629        let first = traversal_source_watermark(root, &hint, None, true)
33630            .expect("first watermark call must succeed")
33631            .expect("first watermark must produce a hash");
33632
33633        std::thread::sleep(std::time::Duration::from_millis(20));
33634        let conn = Connection::open(&summaries_db_path).unwrap();
33635        conn.pragma_update(None, "user_version", 1).unwrap();
33636        conn.pragma_update(None, "user_version", 0).unwrap();
33637        drop(conn);
33638
33639        let second = traversal_source_watermark(root, &hint, None, true)
33640            .expect("second watermark call must succeed")
33641            .expect("second watermark must produce a hash");
33642        assert_eq!(
33643            first, second,
33644            "metadata-only summaries.db churn must not invalidate the source watermark"
33645        );
33646
33647        summary.entities = Some(vec![summarize::Entity {
33648            name: "GraphCache".to_string(),
33649            kind: "type".to_string(),
33650            description: "Stable full-projection cache input".to_string(),
33651        }]);
33652        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33653        summary_db.delete_by_file("src/main.rs").unwrap();
33654        summary_db.insert(&summary).unwrap();
33655        drop(summary_db);
33656
33657        let third = traversal_source_watermark(root, &hint, None, true)
33658            .expect("third watermark call must succeed")
33659            .expect("third watermark must produce a hash");
33660        assert_ne!(
33661            first, third,
33662            "semantic summary row changes must invalidate the source watermark"
33663        );
33664    }
33665
33666    #[test]
33667    fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
33668        // #gfullhot: backend-eval full-projection cache keys should be based on
33669        // the indexed graph inputs, not file_state mtimes. Touching a source file
33670        // without changing extracted symbols/call edges must still hit the cache.
33671        let dir = tempfile::tempdir().unwrap();
33672        let root = dir.path();
33673        std::fs::create_dir_all(root.join("src")).unwrap();
33674        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33675        let source = root.join("src/lib.rs");
33676        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33677        std::fs::write(&source, source_body).unwrap();
33678        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33679        db.rebuild(root).unwrap();
33680        drop(db);
33681
33682        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33683            .unwrap()
33684            .value;
33685        std::thread::sleep(std::time::Duration::from_millis(20));
33686        std::fs::write(&source, source_body).unwrap();
33687        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33688        db.apply_changes(root).unwrap();
33689        drop(db);
33690
33691        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33692            .unwrap()
33693            .value;
33694        assert_eq!(
33695            first, second,
33696            "mtime-only source index churn must not invalidate the full-projection cache"
33697        );
33698    }
33699
33700    #[test]
33701    fn full_projection_source_watermark_ignores_session_markdown_churn() {
33702        // #gfullhot: the full-projection performance cache isolates code graph
33703        // and semantic-summary inputs. Current session evidence is measured by
33704        // the bounded real dataset, so unrelated task-doc edits must not force a
33705        // million-row full-projection rebuild.
33706        let dir = tempfile::tempdir().unwrap();
33707        let root = dir.path();
33708        std::fs::create_dir_all(root.join("src")).unwrap();
33709        std::fs::create_dir_all(root.join("tasks/software")).unwrap();
33710        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33711        std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
33712        let task_doc = root.join("tasks/software/tsift.md");
33713        std::fs::write(
33714            &task_doc,
33715            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
33716        )
33717        .unwrap();
33718        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33719        db.rebuild(root).unwrap();
33720        drop(db);
33721
33722        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33723            .unwrap()
33724            .value;
33725        std::fs::write(
33726            &task_doc,
33727            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
33728        )
33729        .unwrap();
33730        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33731            .unwrap()
33732            .value;
33733        assert_eq!(
33734            first, second,
33735            "session markdown churn must not invalidate the full-projection code/summary cache"
33736        );
33737    }
33738
33739    #[test]
33740    fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
33741        // #gfullhot: once a full-project projection is cached, repeated samples
33742        // with unchanged graph inputs must report zero source_graph_build and
33743        // projection_rows work even if indexed file mtimes changed.
33744        let dir = tempfile::tempdir().unwrap();
33745        let root = dir.path();
33746        std::fs::create_dir_all(root.join("src")).unwrap();
33747        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33748        let source = root.join("src/lib.rs");
33749        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33750        std::fs::write(&source, source_body).unwrap();
33751        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33752        db.rebuild(root).unwrap();
33753        drop(db);
33754
33755        let (_projection, _warnings, _phases, first_stats) =
33756            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33757        assert!(
33758            !first_stats.hit,
33759            "the first full-projection run should populate the cache"
33760        );
33761
33762        std::thread::sleep(std::time::Duration::from_millis(20));
33763        std::fs::write(&source, source_body).unwrap();
33764        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33765        db.apply_changes(root).unwrap();
33766        drop(db);
33767
33768        let (_projection, _warnings, phases, second_stats) =
33769            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33770        assert!(second_stats.hit, "mtime-only churn should still cache-hit");
33771        let source_graph_build = phases
33772            .iter()
33773            .find(|phase| phase.name == "full_projection.source_graph_build")
33774            .expect("cache hit must report source_graph_build");
33775        let projection_rows = phases
33776            .iter()
33777            .find(|phase| phase.name == "full_projection.projection_rows")
33778            .expect("cache hit must report projection_rows");
33779        assert_eq!(source_graph_build.duration_micros, 0);
33780        assert_eq!(projection_rows.duration_micros, 0);
33781    }
33782
33783    #[test]
33784    fn build_token_capped_preview_within_cap() {
33785        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 2", "}"];
33786        let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
33787        assert!(!capped.was_capped);
33788        assert_eq!(capped.preview.len(), 3);
33789        assert_eq!(capped.capped_end, 3);
33790    }
33791
33792    #[test]
33793    fn build_token_capped_preview_truncates_long_body() {
33794        let owned: Vec<String> = (0..200)
33795            .map(|i| format!("    let line_{i} = {i};"))
33796            .collect();
33797        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33798        let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
33799        assert!(capped.was_capped);
33800        assert!(capped.preview.len() < 200);
33801        assert!(capped.capped_end < 200);
33802        assert!(!capped.preview.is_empty());
33803    }
33804
33805    #[test]
33806    fn build_token_capped_preview_respects_start_offset() {
33807        let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
33808        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33809        let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
33810        assert!(capped.was_capped);
33811        assert!(capped.capped_end >= 50);
33812        assert!(capped.capped_end < 100);
33813        assert_eq!(capped.preview[0].line, 50);
33814    }
33815
33816    #[test]
33817    fn response_budget_body_token_cap_defaults() {
33818        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
33819        assert_eq!(budget.body_token_cap(), 1500);
33820
33821        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
33822        assert_eq!(budget.body_token_cap(), 500);
33823
33824        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
33825        assert_eq!(budget.body_token_cap(), 3000);
33826    }
33827
33828    #[test]
33829    fn build_token_capped_preview_empty_input() {
33830        let lines: Vec<&str> = vec![];
33831        let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
33832        assert!(!capped.was_capped);
33833        assert!(capped.preview.is_empty());
33834    }
33835
33836    #[test]
33837    fn build_token_capped_preview_single_long_line_fits() {
33838        let lines: Vec<&str> = vec!["short"];
33839        let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
33840        assert!(!capped.was_capped);
33841        assert_eq!(capped.preview.len(), 1);
33842        assert_eq!(capped.capped_end, 1);
33843    }
33844
33845    #[test]
33846    fn edge_index_replaces_from_id_to_id_with_positions() {
33847        let input = serde_json::json!({
33848            "nodes": [
33849                {"id": "symbol:src/lib.rs:foo"},
33850                {"id": "symbol:src/lib.rs:bar"},
33851                {"id": "symbol:src/lib.rs:baz"}
33852            ],
33853            "edges": [
33854                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
33855                {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
33856            ]
33857        });
33858        let result = edge_index_transform(input);
33859        let edges = result.get("edges").unwrap().as_array().unwrap();
33860        assert_eq!(edges.len(), 2);
33861        assert_eq!(edges[0]["from"], 0);
33862        assert_eq!(edges[0]["to"], 1);
33863        assert_eq!(edges[1]["from"], 1);
33864        assert_eq!(edges[1]["to"], 2);
33865        assert!(edges[0].get("from_id").is_none());
33866        assert!(edges[0].get("to_id").is_none());
33867    }
33868
33869    #[test]
33870    fn edge_index_preserves_unresolved_ids_as_strings() {
33871        let input = serde_json::json!({
33872            "nodes": [{"id": "symbol:src/lib.rs:foo"}],
33873            "edges": [
33874                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
33875            ]
33876        });
33877        let result = edge_index_transform(input);
33878        let edge = &result["edges"][0];
33879        assert_eq!(edge["from"], 0);
33880        assert_eq!(edge["to_id"], "symbol:other.rs:missing");
33881    }
33882
33883    #[test]
33884    fn edge_index_noop_without_nodes_and_edges() {
33885        let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
33886        let result = edge_index_transform(input);
33887        assert_eq!(result["report"]["entries"][0]["from_id"], "a");
33888    }
33889}
33890
33891// --- SQL introspection ---
33892
33893#[derive(Serialize)]
33894struct TableInfo {
33895    name: String,
33896    columns: Vec<ColumnInfo>,
33897    row_count: i64,
33898}
33899
33900#[derive(Serialize)]
33901struct ColumnInfo {
33902    name: String,
33903    #[serde(rename = "type")]
33904    col_type: String,
33905    notnull: bool,
33906    pk: bool,
33907    #[serde(skip_serializing_if = "Option::is_none")]
33908    default_value: Option<String>,
33909}
33910
33911/// Open a SQLite connection (read-only).
33912pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
33913    let conn = Connection::open_with_flags(
33914        path,
33915        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
33916    )
33917    .with_context(|| format!("opening database: {}", path.display()))?;
33918    Ok(conn)
33919}
33920
33921/// List all user tables with column metadata and row counts.
33922pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
33923    let mut stmt = conn.prepare(
33924        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
33925    )?;
33926    let table_names: Vec<String> = stmt
33927        .query_map([], |row| row.get(0))?
33928        .collect::<std::result::Result<Vec<_>, _>>()?;
33929
33930    let mut tables = Vec::new();
33931    for tbl in table_names {
33932        let columns = table_columns(conn, &tbl)?;
33933        let row_count: i64 =
33934            conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
33935                row.get(0)
33936            })?;
33937        tables.push(TableInfo {
33938            name: tbl,
33939            columns,
33940            row_count,
33941        });
33942    }
33943    Ok(tables)
33944}
33945
33946/// Get column metadata for a single table.
33947pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
33948    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
33949    let cols = stmt
33950        .query_map([], |row| {
33951            Ok(ColumnInfo {
33952                name: row.get(1)?,
33953                col_type: row.get::<_, String>(2).unwrap_or_default(),
33954                notnull: row.get::<_, bool>(3).unwrap_or(false),
33955                pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
33956                default_value: row.get(4)?,
33957            })
33958        })?
33959        .collect::<std::result::Result<Vec<_>, _>>()?;
33960    Ok(cols)
33961}
33962
33963/// Execute an arbitrary SQL query and return rows as JSON values.
33964pub(crate) fn execute_query(
33965    conn: &Connection,
33966    sql: &str,
33967) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
33968    let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
33969    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
33970    let col_count = col_names.len();
33971
33972    let mut rows = Vec::new();
33973    let mut query_rows = stmt.query([])?;
33974    while let Some(row) = query_rows.next()? {
33975        let mut vals = Vec::with_capacity(col_count);
33976        for i in 0..col_count {
33977            let val = match row.get_ref(i)? {
33978                rusqlite::types::ValueRef::Null => serde_json::Value::Null,
33979                rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
33980                rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
33981                rusqlite::types::ValueRef::Text(s) => {
33982                    serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
33983                }
33984                rusqlite::types::ValueRef::Blob(b) => {
33985                    serde_json::Value::String(format!("<blob {} bytes>", b.len()))
33986                }
33987            };
33988            vals.push(val);
33989        }
33990        rows.push(vals);
33991    }
33992    Ok((col_names, rows))
33993}
33994
33995#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33996enum DigestRunnerKind {
33997    Test,
33998    Log,
33999}
34000
34001impl DigestRunnerKind {
34002    fn parse(raw: &str) -> Result<Self> {
34003        match raw.trim().to_ascii_lowercase().as_str() {
34004            "test" => Ok(Self::Test),
34005            "log" => Ok(Self::Log),
34006            other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
34007        }
34008    }
34009
34010    fn as_str(self) -> &'static str {
34011        match self {
34012            Self::Test => "test",
34013            Self::Log => "log",
34014        }
34015    }
34016}
34017
34018/// Simple shell word splitting (handles single and double quotes).
34019pub(crate) fn shell_split(s: &str) -> Vec<&str> {
34020    let mut parts = Vec::new();
34021    let mut i = 0;
34022    let bytes = s.as_bytes();
34023    while i < bytes.len() {
34024        // Skip whitespace
34025        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
34026            i += 1;
34027        }
34028        if i >= bytes.len() {
34029            break;
34030        }
34031        let start = i;
34032        if bytes[i] == b'"' || bytes[i] == b'\'' {
34033            let quote = bytes[i];
34034            i += 1;
34035            while i < bytes.len() && bytes[i] != quote {
34036                i += 1;
34037            }
34038            if i < bytes.len() {
34039                i += 1; // closing quote
34040            }
34041        } else {
34042            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
34043                i += 1;
34044            }
34045        }
34046        parts.push(&s[start..i]);
34047    }
34048    parts
34049}
34050
34051/// Quote a string for shell if it contains special characters.
34052pub(crate) fn shell_quote(s: &str) -> String {
34053    // Strip existing quotes
34054    let unquoted =
34055        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
34056            &s[1..s.len() - 1]
34057        } else {
34058            s
34059        };
34060
34061    if unquoted
34062        .chars()
34063        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
34064    {
34065        format!("\"{}\"", unquoted)
34066    } else {
34067        format!(
34068            "\"{}\"",
34069            unquoted.replace('\\', "\\\\").replace('"', "\\\"")
34070        )
34071    }
34072}
34073
34074fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
34075    sift::SearchCoverageSnapshot {
34076        mode: sift::SearchCoverageMode::Sealed,
34077        total_sector_count: 0,
34078        mounted_sector_count: 0,
34079        reused_sector_count: 0,
34080        dirty_sector_count: 0,
34081        completed_dirty_sector_count: 0,
34082        rebuilding_sector_count: 0,
34083        resumed_sector_count: 0,
34084        active_rebuild: None,
34085    }
34086}
34087
34088fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
34089    let total_sector_count = responses
34090        .iter()
34091        .map(|response| response.coverage.total_sector_count)
34092        .sum();
34093    let mounted_sector_count = responses
34094        .iter()
34095        .map(|response| response.coverage.mounted_sector_count)
34096        .sum();
34097    let reused_sector_count = responses
34098        .iter()
34099        .map(|response| response.coverage.reused_sector_count)
34100        .sum();
34101    let dirty_sector_count = responses
34102        .iter()
34103        .map(|response| response.coverage.dirty_sector_count)
34104        .sum();
34105    let completed_dirty_sector_count = responses
34106        .iter()
34107        .map(|response| response.coverage.completed_dirty_sector_count)
34108        .sum();
34109    let rebuilding_sector_count = responses
34110        .iter()
34111        .map(|response| response.coverage.rebuilding_sector_count)
34112        .sum();
34113    let resumed_sector_count = responses
34114        .iter()
34115        .map(|response| response.coverage.resumed_sector_count)
34116        .sum();
34117
34118    let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
34119        sift::SearchCoverageMode::Sealed
34120    } else if completed_dirty_sector_count > 0
34121        || rebuilding_sector_count > 0
34122        || resumed_sector_count > 0
34123    {
34124        sift::SearchCoverageMode::Converging
34125    } else {
34126        sift::SearchCoverageMode::Frontier
34127    };
34128
34129    sift::SearchCoverageSnapshot {
34130        mode,
34131        total_sector_count,
34132        mounted_sector_count,
34133        reused_sector_count,
34134        dirty_sector_count,
34135        completed_dirty_sector_count,
34136        rebuilding_sector_count,
34137        resumed_sector_count,
34138        active_rebuild: responses
34139            .iter()
34140            .find_map(|response| response.coverage.active_rebuild.clone()),
34141    }
34142}
34143
34144fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
34145    sift::SearchResponse {
34146        strategy: strategy.to_string(),
34147        root: root.display().to_string(),
34148        indexed_artifacts: 0,
34149        skipped_artifacts: 0,
34150        coverage: empty_search_coverage(),
34151        hits: Vec::new(),
34152    }
34153}
34154
34155fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
34156    for hit in &mut response.hits {
34157        let path = Path::new(&hit.path);
34158        if path.is_relative() {
34159            hit.path = search_root.join(path).display().to_string();
34160        }
34161    }
34162}
34163
34164fn merge_search_responses(
34165    root: &Path,
34166    strategy: &str,
34167    limit: usize,
34168    responses: Vec<sift::SearchResponse>,
34169) -> sift::SearchResponse {
34170    let indexed_artifacts = responses
34171        .iter()
34172        .map(|response| response.indexed_artifacts)
34173        .sum();
34174    let skipped_artifacts = responses
34175        .iter()
34176        .map(|response| response.skipped_artifacts)
34177        .sum();
34178    let coverage = if responses.is_empty() {
34179        empty_search_coverage()
34180    } else {
34181        aggregate_search_coverage(&responses)
34182    };
34183    let mut hits: Vec<sift::SearchHit> = responses
34184        .into_iter()
34185        .flat_map(|response| response.hits)
34186        .collect();
34187    hits.sort_by(|left, right| {
34188        right
34189            .score
34190            .partial_cmp(&left.score)
34191            .unwrap_or(Ordering::Equal)
34192            .then_with(|| left.path.cmp(&right.path))
34193            .then_with(|| left.location.cmp(&right.location))
34194    });
34195    hits.truncate(limit);
34196    for (rank, hit) in hits.iter_mut().enumerate() {
34197        hit.rank = rank + 1;
34198    }
34199
34200    sift::SearchResponse {
34201        strategy: strategy.to_string(),
34202        root: root.display().to_string(),
34203        indexed_artifacts,
34204        skipped_artifacts,
34205        coverage,
34206        hits,
34207    }
34208}
34209
34210pub(crate) fn federated_sift_search(
34211    root: &Path,
34212    cache_dir: &Path,
34213    query: &str,
34214    limit: usize,
34215    timeout_secs: u64,
34216    strategy: &str,
34217    fts_index_fresh: Option<bool>,
34218) -> Result<sift::SearchResponse> {
34219    let targets = resolve_search_index_targets(root, root, None, true)?;
34220    if targets.is_empty() {
34221        if config::Config::submodule_dirs(root)?.is_empty() {
34222            return run_search_with_timeout(
34223                root,
34224                cache_dir,
34225                query,
34226                limit,
34227                timeout_secs,
34228                strategy,
34229                &[],
34230                fts_index_fresh,
34231            );
34232        }
34233        return Ok(empty_search_response(root, strategy));
34234    }
34235
34236    let mut responses = Vec::with_capacity(targets.len());
34237    for target in &targets {
34238        let mut response = run_search_with_timeout(
34239            &target.source_root,
34240            cache_dir,
34241            query,
34242            limit,
34243            timeout_secs,
34244            strategy,
34245            std::slice::from_ref(target),
34246            fts_index_fresh,
34247        )?;
34248        absolutize_search_hit_paths(&mut response, &target.source_root);
34249        response.root = root.display().to_string();
34250        responses.push(response);
34251    }
34252
34253    Ok(merge_search_responses(root, strategy, limit, responses))
34254}
34255
34256/// Federated symbol search across every scoped `.tsift/indexes/<scope>/index.db`
34257/// in the workspace. Per-scope tagpath annotation runs inside the per-scope
34258/// loop so each scope's adapter resolves against its own `.naming.toml` /
34259/// `.naming/index.json` (the workspace root usually has no tagpath of its
34260/// own). The merged `TagpathAnnotationDiagnostic` reports `loaded=true` when
34261/// at least one scope loaded, and `stale=true` with the first stale reason
34262/// when any scope was stale.
34263pub(crate) fn federated_symbol_search(
34264    root: &std::path::Path,
34265    query: &str,
34266    limit: usize,
34267    tagpath_opts: &TagpathSearchOpts,
34268) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
34269    let cfg = config::Config::load(root)?;
34270    let submodules = config::Config::submodule_dirs(root)?;
34271    let mut all_hits: Vec<index::SymbolHit> = Vec::new();
34272    let mut combined = TagpathAnnotationDiagnostic::default();
34273    for scope in &submodules {
34274        if !cfg.federation_for_scope(scope) {
34275            continue;
34276        }
34277        let db_path = cfg.db_path_for(root, &scope.id);
34278        if !db_path.exists() {
34279            continue;
34280        }
34281        let db = index::IndexDb::open_read_only(&db_path)?;
34282        let mut hits = db.symbol_search(query, limit)?;
34283        let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
34284        combined.loaded |= diag.loaded;
34285        if diag.stale && !combined.stale {
34286            combined.stale = true;
34287            combined.reason = diag.reason;
34288        }
34289        all_hits.append(&mut hits);
34290    }
34291    all_hits.sort_by(|a, b| {
34292        b.score
34293            .partial_cmp(&a.score)
34294            .unwrap_or(std::cmp::Ordering::Equal)
34295    });
34296    all_hits.truncate(limit);
34297    Ok((all_hits, combined))
34298}
34299
34300#[derive(Debug, Deserialize)]
34301#[serde(tag = "type", rename_all = "lowercase")]
34302enum RipgrepJsonEvent {
34303    Match {
34304        data: RipgrepMatchData,
34305    },
34306    #[serde(other)]
34307    Other,
34308}
34309
34310#[derive(Debug, Deserialize)]
34311struct RipgrepMatchData {
34312    path: RipgrepTextField,
34313    lines: RipgrepTextField,
34314    line_number: Option<usize>,
34315}
34316
34317#[derive(Debug, Deserialize)]
34318struct RipgrepTextField {
34319    text: Option<String>,
34320}
34321
34322pub(crate) fn federated_exact_search(
34323    root: &Path,
34324    query: &str,
34325    limit: usize,
34326    timeout_secs: u64,
34327) -> Result<sift::SearchResponse> {
34328    let cfg = config::Config::load(root)?;
34329    let mut responses = Vec::new();
34330    for scope in config::Config::submodule_dirs(root)? {
34331        if !cfg.federation_for_scope(&scope) {
34332            continue;
34333        }
34334        let mut response =
34335            run_exact_search_with_timeout(std::slice::from_ref(&scope.source_root), query, limit, timeout_secs)?;
34336        absolutize_search_hit_paths(&mut response, &scope.source_root);
34337        response.root = root.display().to_string();
34338        responses.push(response);
34339    }
34340
34341    Ok(merge_search_responses(root, "exact", limit, responses))
34342}
34343
34344pub(crate) fn run_sift_search(
34345    search_path: &Path,
34346    cache_dir: &Path,
34347    query: &str,
34348    limit: usize,
34349    strategy: &str,
34350    // #015t Phase 4b — the caller's already-known FTS index freshness:
34351    //   `Some(true)`  — caller (cmd_search after precheck+autoindex) proved fresh;
34352    //                   use FTS without re-walking the tree.
34353    //   `Some(false)` — caller proved stale/degraded (e.g. read-only writer lock);
34354    //                   skip FTS, serve live results via the TokenIndex fallback.
34355    //   `None`        — unknown (direct programmatic callers); inspect here.
34356    // This drops the redundant `inspect_read_only` walk on the normal CLI path,
34357    // where `precheck_search_indexes` already established freshness.
34358    fts_index_fresh: Option<bool>,
34359) -> Result<sift::SearchResponse> {
34360    // #015t Phase 4 cutover: the `index.db` FTS5 path is now the DEFAULT for
34361    // lexical search. The normal search flow runs `precheck_search_indexes` with
34362    // autoindex first, so a fresh root `index.db` is guaranteed present before we
34363    // get here; the FTS5 BM25 path supersedes the parallel JSON `TokenIndex`
34364    // (which never rebuilt on content change — staleness was keyed on file
34365    // existence only). Ranking shifts from substring-position to BM25 by design;
34366    // the Phase 3 soundness gate proved candidate coverage (FTS ⊇ TokenIndex).
34367    //
34368    // The JSON `TokenIndex` is demoted to a FALLBACK for the only remaining cases
34369    // that reach here without a fresh root index.db: an un-indexed root reached
34370    // with `--no-autoindex` (the normal precheck degrades a missing index to exact
34371    // search, not lexical), a stale/degraded index (live results via TokenIndex),
34372    // and direct programmatic callers. `TSIFT_FTS_SEARCH=0` (`0`/`false`/`no`/`off`)
34373    // forces that legacy path as a transition escape hatch. Both the in-process
34374    // (timeout=0) and `__search-worker` subprocess routes pass through here; the
34375    // worker inherits the env var and is handed the freshness verdict explicitly.
34376    if !fts_search_forced_off() {
34377        let db_path = search_path.join(".tsift/index.db");
34378        let use_fts = match fts_index_fresh {
34379            Some(fresh) => fresh && db_path.exists(),
34380            None => db_path.exists() && index_db_is_fresh_for_fts(&db_path, search_path),
34381        };
34382        if use_fts {
34383            return sift::fts_search(&db_path, search_path, query, limit)
34384                .context("index.db FTS5 search failed");
34385        }
34386    }
34387
34388    let engine = Sift::builder().with_cache_dir(cache_dir).build();
34389    let options = SearchOptions::default()
34390        .with_limit(limit)
34391        .with_strategy(strategy.to_string());
34392    let input = SearchInput::new(search_path, query).with_options(options);
34393    engine.search(input).context("sift search failed")
34394}
34395
34396/// #015t Phase 4 — the FTS5 `index.db` path is trustworthy only when the index is
34397/// **openable AND fresh** for the search root. A missing/corrupt index.db (e.g. an
34398/// empty placeholder) or a **stale** one (e.g. held open by a concurrent writer so
34399/// autoindex degraded to read-only) falls back to the live `TokenIndex` path — so a
34400/// search never returns content the index has not caught up to. The normal flow's
34401/// `precheck_search_indexes` + autoindex makes this true in the common case;
34402/// re-inspecting here is a redundant tree-walk that #015t Phase 4b can replace by
34403/// threading the precheck's freshness result through to this call.
34404fn index_db_is_fresh_for_fts(db_path: &Path, search_path: &Path) -> bool {
34405    match index::IndexDb::inspect_read_only(db_path, search_path, false) {
34406        Ok(inspection) => {
34407            inspection.summary.new + inspection.summary.modified + inspection.summary.deleted == 0
34408        }
34409        Err(_) => false,
34410    }
34411}
34412
34413/// #015t Phase 4 — whether the operator has forced lexical search back onto the
34414/// legacy JSON `TokenIndex` path via `TSIFT_FTS_SEARCH` set to a falsy value
34415/// (`0`/`false`/`no`/`off`). The FTS5 `index.db` path is the default; this is the
34416/// transition escape hatch. Any other value (or unset) keeps the FTS5 default.
34417fn fts_search_forced_off() -> bool {
34418    std::env::var("TSIFT_FTS_SEARCH")
34419        .map(|value| fts_flag_value_disabled(&value))
34420        .unwrap_or(false)
34421}
34422
34423/// Pure parser for a falsy `TSIFT_FTS_SEARCH` value (factored out so the rules
34424/// can be unit-tested without mutating process env).
34425fn fts_flag_value_disabled(value: &str) -> bool {
34426    matches!(
34427        value.trim().to_ascii_lowercase().as_str(),
34428        "0" | "false" | "no" | "off"
34429    )
34430}
34431
34432fn exact_search_timeout_message(timeout_secs: u64) -> String {
34433    format!(
34434        "tsift search timed out after {}s (strategy: exact). \
34435         Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
34436        timeout_secs
34437    )
34438}
34439
34440fn exact_search_command(search_paths: &[PathBuf], query: &str) -> Command {
34441    let mut command = Command::new("rg");
34442    command
34443        .arg("--json")
34444        .arg("--fixed-strings")
34445        .arg("--line-number")
34446        .arg("--hidden")
34447        .arg("--")
34448        .arg(query);
34449    if search_paths.is_empty() {
34450        command.arg(Path::new("."));
34451    } else {
34452        command.args(search_paths);
34453    }
34454    command
34455}
34456
34457fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
34458    let observed_unix_secs = SystemTime::now()
34459        .duration_since(UNIX_EPOCH)
34460        .unwrap_or_default()
34461        .as_secs() as i64;
34462    let modified_unix_secs = fs::metadata(path)
34463        .ok()
34464        .and_then(|metadata| metadata.modified().ok())
34465        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
34466        .map(|duration| duration.as_secs() as i64);
34467    sift::ArtifactFreshness {
34468        observed_unix_secs,
34469        modified_unix_secs,
34470    }
34471}
34472
34473fn parse_exact_search_output(
34474    search_path: &Path,
34475    limit: usize,
34476    raw: &str,
34477) -> Result<sift::SearchResponse> {
34478    if limit == 0 {
34479        return Ok(sift::SearchResponse {
34480            strategy: "exact".to_string(),
34481            root: search_path.display().to_string(),
34482            indexed_artifacts: 0,
34483            skipped_artifacts: 0,
34484            coverage: empty_search_coverage(),
34485            hits: Vec::new(),
34486        });
34487    }
34488
34489    let mut hits = Vec::new();
34490    for line in raw.lines() {
34491        let event: RipgrepJsonEvent =
34492            serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
34493        let RipgrepJsonEvent::Match { data } = event else {
34494            continue;
34495        };
34496        let Some(path_text) = data.path.text else {
34497            continue;
34498        };
34499        let Some(lines_text) = data.lines.text else {
34500            continue;
34501        };
34502        let path = PathBuf::from(path_text);
34503        let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
34504        let rank = hits.len() + 1;
34505        hits.push(sift::SearchHit {
34506            artifact_id: format!(
34507                "exact:{}:{}:{}",
34508                path.display(),
34509                data.line_number.unwrap_or(0),
34510                rank
34511            ),
34512            artifact_kind: sift::ContextArtifactKind::File,
34513            path: path.display().to_string(),
34514            rank,
34515            score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
34516            confidence: sift::ScoreConfidence::High,
34517            location: data.line_number.map(|line| format!("line {}", line)),
34518            snippet: snippet.clone(),
34519            provenance: sift::ArtifactProvenance {
34520                adapter: sift::AcquisitionAdapterKind::FileSystem,
34521                source: "ripgrep -F".to_string(),
34522                synthetic: false,
34523            },
34524            freshness: exact_search_file_timestamp(&path),
34525            budget: sift::ArtifactBudget::from_text(&snippet, 1),
34526        });
34527        if hits.len() >= limit {
34528            break;
34529        }
34530    }
34531
34532    Ok(sift::SearchResponse {
34533        strategy: "exact".to_string(),
34534        root: search_path.display().to_string(),
34535        indexed_artifacts: hits.len(),
34536        skipped_artifacts: 0,
34537        coverage: empty_search_coverage(),
34538        hits,
34539    })
34540}
34541
34542fn exact_search_response_from_process(
34543    search_path: &Path,
34544    limit: usize,
34545    status: std::process::ExitStatus,
34546    stdout: &[u8],
34547    stderr: &[u8],
34548) -> Result<sift::SearchResponse> {
34549    if !status.success() && status.code() != Some(1) {
34550        let message = String::from_utf8_lossy(stderr);
34551        let trimmed = message.trim();
34552        if trimmed.is_empty() {
34553            bail!("ripgrep exact search exited with status {}", status);
34554        }
34555        bail!("{}", trimmed);
34556    }
34557
34558    let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
34559    parse_exact_search_output(search_path, limit, &raw)
34560}
34561
34562fn run_exact_search(search_paths: &[PathBuf], query: &str, limit: usize) -> Result<sift::SearchResponse> {
34563    let output = exact_search_command(search_paths, query)
34564        .output()
34565        .context("running exact search with ripgrep")?;
34566    let root_display = search_paths
34567        .first()
34568        .map(|p| p.as_path())
34569        .unwrap_or_else(|| Path::new("."));
34570    exact_search_response_from_process(
34571        root_display,
34572        limit,
34573        output.status,
34574        &output.stdout,
34575        &output.stderr,
34576    )
34577}
34578
34579pub(crate) fn run_exact_search_with_timeout(
34580    search_paths: &[PathBuf],
34581    query: &str,
34582    limit: usize,
34583    timeout_secs: u64,
34584) -> Result<sift::SearchResponse> {
34585    if timeout_secs == 0 {
34586        return run_exact_search(search_paths, query, limit);
34587    }
34588
34589    let mut child = exact_search_command(search_paths, query)
34590        .stdin(Stdio::null())
34591        .stdout(Stdio::piped())
34592        .stderr(Stdio::piped())
34593        .spawn()
34594        .context("spawning timed exact search worker")?;
34595
34596    let timeout = Duration::from_secs(timeout_secs);
34597    let status = wait_for_child_exit(&mut child, timeout)
34598        .context("waiting for timed exact search worker")?;
34599    if status.is_none() {
34600        let _ = child.kill();
34601        let _ = child.wait();
34602        bail!("{}", exact_search_timeout_message(timeout_secs));
34603    }
34604
34605    let status = status.unwrap();
34606    let stdout = read_child_stdout(&mut child)?;
34607    let stderr = read_child_stderr(&mut child)?;
34608    let root_display = search_paths
34609        .first()
34610        .map(|p| p.as_path())
34611        .unwrap_or_else(|| Path::new("."));
34612    exact_search_response_from_process(
34613        root_display,
34614        limit,
34615        status,
34616        stdout.as_bytes(),
34617        stderr.as_bytes(),
34618    )
34619}
34620
34621#[allow(clippy::too_many_arguments)]
34622pub(crate) fn run_search_with_timeout(
34623    search_path: &Path,
34624    cache_dir: &Path,
34625    query: &str,
34626    limit: usize,
34627    timeout_secs: u64,
34628    strategy: &str,
34629    search_targets: &[SearchIndexTarget],
34630    // #015t Phase 4b — FTS index freshness verdict forwarded to the worker so it
34631    // skips the redundant `inspect_read_only` walk (see `run_sift_search`).
34632    fts_index_fresh: Option<bool>,
34633) -> Result<sift::SearchResponse> {
34634    if timeout_secs == 0 {
34635        return run_sift_search(search_path, cache_dir, query, limit, strategy, fts_index_fresh);
34636    }
34637
34638    let output_path = next_search_worker_output_path();
34639    let mut command = Command::new(
34640        std::env::current_exe().context("resolving tsift executable for timed search")?,
34641    );
34642    command
34643        .arg("__search-worker")
34644        .arg("--path")
34645        .arg(search_path)
34646        .arg("--cache-dir")
34647        .arg(cache_dir)
34648        .arg("--query")
34649        .arg(query)
34650        .arg("--limit")
34651        .arg(limit.to_string())
34652        .arg("--strategy")
34653        .arg(strategy)
34654        .arg("--output")
34655        .arg(&output_path);
34656    if let Some(fresh) = fts_index_fresh {
34657        command.arg("--fts-index-fresh").arg(fresh.to_string());
34658    }
34659    let mut child = command
34660        .stdin(Stdio::null())
34661        .stdout(Stdio::null())
34662        .stderr(Stdio::piped())
34663        .spawn()
34664        .context("spawning timed sift search worker")?;
34665
34666    let timeout = Duration::from_secs(timeout_secs);
34667    let status =
34668        wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
34669    if status.is_none() {
34670        let _ = child.kill();
34671        let _ = child.wait();
34672        let _ = fs::remove_file(&output_path);
34673        bail!(
34674            "{}",
34675            search_timeout_message(timeout_secs, strategy, search_targets)?
34676        );
34677    }
34678
34679    let status = status.unwrap();
34680    let stderr = read_child_stderr(&mut child)?;
34681    if !status.success() {
34682        let _ = fs::remove_file(&output_path);
34683        let message = stderr.trim();
34684        if message.is_empty() {
34685            bail!("sift search worker exited with status {}", status);
34686        }
34687        bail!("{}", message);
34688    }
34689
34690    let raw = fs::read_to_string(&output_path)
34691        .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
34692    let _ = fs::remove_file(&output_path);
34693    serde_json::from_str(&raw).context("parsing search worker output")
34694}
34695
34696fn next_search_worker_output_path() -> PathBuf {
34697    let stamp = SystemTime::now()
34698        .duration_since(UNIX_EPOCH)
34699        .unwrap_or_default()
34700        .as_nanos();
34701    std::env::temp_dir().join(format!(
34702        "tsift-search-{}-{}.json",
34703        std::process::id(),
34704        stamp
34705    ))
34706}
34707
34708fn wait_for_child_exit(
34709    child: &mut std::process::Child,
34710    timeout: Duration,
34711) -> Result<Option<std::process::ExitStatus>> {
34712    let started = Instant::now();
34713    loop {
34714        if let Some(status) = child.try_wait()? {
34715            return Ok(Some(status));
34716        }
34717        if started.elapsed() >= timeout {
34718            return Ok(None);
34719        }
34720        let remaining = timeout.saturating_sub(started.elapsed());
34721        std::thread::sleep(remaining.min(Duration::from_millis(10)));
34722    }
34723}
34724
34725fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
34726    let mut stderr = String::new();
34727    if let Some(mut pipe) = child.stderr.take() {
34728        pipe.read_to_string(&mut stderr)
34729            .context("reading search worker stderr")?;
34730    }
34731    Ok(stderr)
34732}
34733
34734fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
34735    let mut stdout = String::new();
34736    if let Some(mut pipe) = child.stdout.take() {
34737        pipe.read_to_string(&mut stdout)
34738            .context("reading search worker stdout")?;
34739    }
34740    Ok(stdout)
34741}
34742
34743pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
34744    if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
34745        fs::write(&path, std::process::id().to_string())
34746            .with_context(|| format!("writing search worker pid file: {path}"))?;
34747    }
34748    if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
34749        let delay_ms = ms
34750            .parse::<u64>()
34751            .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
34752        std::thread::sleep(Duration::from_millis(delay_ms));
34753    }
34754    Ok(())
34755}
34756
34757#[cfg(test)]
34758thread_local! {
34759    static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
34760}
34761
34762#[cfg(test)]
34763enum SearchPostPrecheckLockMode {
34764    RollbackJournal,
34765    Wal,
34766}
34767
34768#[cfg(test)]
34769struct SearchPostPrecheckLockHook {
34770    db_path: PathBuf,
34771    mode: SearchPostPrecheckLockMode,
34772}
34773
34774#[cfg(test)]
34775struct SearchPostPrecheckLockGuard;
34776
34777#[cfg(test)]
34778impl Drop for SearchPostPrecheckLockGuard {
34779    fn drop(&mut self) {
34780        SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34781            hook.borrow_mut().take();
34782        });
34783    }
34784}
34785
34786#[cfg(test)]
34787fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34788    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
34789}
34790
34791#[cfg(test)]
34792fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34793    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
34794}
34795
34796#[cfg(test)]
34797fn install_search_post_precheck_lock_hook(
34798    db_path: PathBuf,
34799    mode: SearchPostPrecheckLockMode,
34800) -> SearchPostPrecheckLockGuard {
34801    SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34802        assert!(
34803            hook.borrow().is_none(),
34804            "search post-precheck lock hook already installed"
34805        );
34806        *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
34807    });
34808    SearchPostPrecheckLockGuard
34809}
34810
34811#[cfg(test)]
34812pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34813    let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
34814        return Ok(());
34815    };
34816    let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
34817    std::thread::spawn(move || {
34818        let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
34819        match hook.mode {
34820            SearchPostPrecheckLockMode::RollbackJournal => {
34821                conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
34822                    .expect("acquiring rollback-journal hook lock");
34823                fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
34824                    .expect("writing rollback journal marker");
34825            }
34826            SearchPostPrecheckLockMode::Wal => {
34827                conn.execute_batch(
34828                    "PRAGMA journal_mode=WAL;
34829                     PRAGMA wal_autocheckpoint=0;
34830                     CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
34831                     INSERT INTO search_wal_lock_probe DEFAULT VALUES;
34832                     PRAGMA locking_mode=EXCLUSIVE;
34833                     BEGIN EXCLUSIVE;",
34834                )
34835                .expect("acquiring WAL hook lock");
34836                assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
34837            }
34838        }
34839        ready_tx.send(()).expect("signaling search lock hook");
34840        std::thread::sleep(Duration::from_millis(200));
34841        drop(conn);
34842        let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
34843    });
34844    ready_rx
34845        .recv_timeout(Duration::from_secs(1))
34846        .context("waiting for search post-precheck lock hook")?;
34847    Ok(())
34848}
34849
34850#[cfg(not(test))]
34851pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34852    Ok(())
34853}