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(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                let mut symbol_by_file_name_line = HashMap::new();
14381                let mut span_by_file_name_line = HashMap::new();
14382                let mut first_symbol_by_name = BTreeMap::<String, String>::new();
14383                let mut first_span_by_name = BTreeMap::<String, String>::new();
14384                let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
14385                let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
14386                for symbol in symbols.iter().filter(|symbol| {
14387                    !traversal_path_is_generated_artifact(
14388                        root,
14389                        &gate_source_root,
14390                        Path::new(&symbol.file),
14391                    )
14392                }) {
14393                    let node = traversal_symbol_node(root, symbol);
14394                    let file = relativize(&symbol.file, root);
14395                    symbol_by_file_name_line.insert(
14396                        format!("{file}:{}:{}", symbol.line, symbol.name),
14397                        node.handle.clone(),
14398                    );
14399                    first_symbol_by_name
14400                        .entry(symbol.name.clone())
14401                        .or_insert_with(|| node.handle.clone());
14402                    let entry = TraversalSymbolIndexEntry {
14403                        handle: node.handle.clone(),
14404                        tokens: traversal_node_tokens(&node),
14405                        node: node.clone(),
14406                    };
14407                    graph.add_node(node.clone());
14408                    if let Some(file_handle) = file_handle_by_path.get(&file) {
14409                        graph.add_edge(
14410                            file_handle,
14411                            &node.handle,
14412                            "defines",
14413                            Some("file defines symbol".to_string()),
14414                            1,
14415                        );
14416                    }
14417                    if !source_by_file.contains_key(&symbol.file) {
14418                        let source_path =
14419                            traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
14420                        source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
14421                    }
14422                    if let Some(Some(source)) = source_by_file.get(&symbol.file)
14423                        && let Some((ast_node, mut ast_entry)) =
14424                            traversal_ast_span_node(root, symbol, source, &symbols)
14425                    {
14426                        ast_entry.symbol_handle = node.handle.clone();
14427                        ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
14428                        span_by_file_name_line.insert(
14429                            format!("{file}:{}:{}", symbol.line, symbol.name),
14430                            ast_node.handle.clone(),
14431                        );
14432                        first_span_by_name
14433                            .entry(symbol.name.clone())
14434                            .or_insert_with(|| ast_node.handle.clone());
14435                        graph.add_node(ast_node.clone());
14436                        graph.add_edge(
14437                            &node.handle,
14438                            &ast_node.handle,
14439                            "has_ast_span",
14440                            Some("symbol projects to indexed AST span".to_string()),
14441                            1,
14442                        );
14443                        graph.add_edge(
14444                            &ast_node.handle,
14445                            &node.handle,
14446                            "represents_symbol",
14447                            Some("AST span represents indexed symbol".to_string()),
14448                            1,
14449                        );
14450                        ast_entries.push(ast_entry);
14451                    }
14452                    symbol_entries.push(entry);
14453                }
14454                link_ast_navigation_edges(&mut graph, &ast_entries);
14455                link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
14456
14457                if !bounded_session_projection {
14458                    for edge in db.all_stored_edges()? {
14459                        if traversal_path_is_generated_artifact(
14460                            root,
14461                            &gate_source_root,
14462                            Path::new(&edge.caller_file),
14463                        ) {
14464                            continue;
14465                        }
14466                        let caller_file = relativize(&edge.caller_file, root);
14467                        let caller_key =
14468                            format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
14469                        let Some(caller_handle) =
14470                            symbol_by_file_name_line.get(&caller_key).cloned()
14471                        else {
14472                            continue;
14473                        };
14474                        let callee_handle = if let Some(handle) =
14475                            first_symbol_by_name.get(&edge.callee_name)
14476                        {
14477                            handle.clone()
14478                        } else {
14479                            let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
14480                            let handle = node.handle.clone();
14481                            graph.add_node(node);
14482                            handle
14483                        };
14484                        graph.add_edge(
14485                            &caller_handle,
14486                            &callee_handle,
14487                            "calls",
14488                            Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
14489                            1,
14490                        );
14491                        if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
14492                            && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
14493                        {
14494                            graph.add_edge(
14495                                caller_span,
14496                                callee_span,
14497                                "calls",
14498                                Some(format!(
14499                                    "AST call site {}:{}",
14500                                    caller_file, edge.call_site_line
14501                                )),
14502                                1,
14503                            );
14504                        }
14505                    }
14506                }
14507
14508                for route in db.all_routes()? {
14509                    if traversal_path_is_generated_artifact(
14510                        root,
14511                        &gate_source_root,
14512                        Path::new(&route.file),
14513                    ) {
14514                        continue;
14515                    }
14516                    let node = traversal_route_node(root, &route);
14517                    let entry = TraversalRouteIndexEntry {
14518                        handle: node.handle.clone(),
14519                        tokens: traversal_node_tokens(&node),
14520                        node: node.clone(),
14521                    };
14522                    graph.add_node(node.clone());
14523                    if let Some(path) = node.path.as_ref()
14524                        && let Some(file_handle) = file_handle_by_path.get(path)
14525                    {
14526                        graph.add_edge(
14527                            file_handle,
14528                            &node.handle,
14529                            "defines_route",
14530                            Some("file declares route".to_string()),
14531                            1,
14532                        );
14533                    }
14534                    let handler_handle =
14535                        if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
14536                            handle.clone()
14537                        } else {
14538                            let node = traversal_unresolved_symbol_node(root, &route.handler_name);
14539                            let handle = node.handle.clone();
14540                            graph.add_node(node);
14541                            handle
14542                        };
14543                    graph.add_edge(
14544                        &entry.handle,
14545                        &handler_handle,
14546                        "handled_by",
14547                        Some("route handler reference".to_string()),
14548                        1,
14549                    );
14550                    if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
14551                        graph.add_edge(
14552                            &entry.handle,
14553                            handler_span,
14554                            "handled_by",
14555                            Some("route handler AST span".to_string()),
14556                            1,
14557                        );
14558                        graph.add_edge(
14559                            handler_span,
14560                            &entry.handle,
14561                            "handles_route",
14562                            Some("AST span handles route".to_string()),
14563                            1,
14564                        );
14565                    }
14566                    route_entries.push(entry);
14567                }
14568            }
14569            _ => {
14570                add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
14571                    .with_context(|| {
14572                    format!(
14573                        "loading raw source fallback nodes from {}",
14574                        gate_source_root.display()
14575                    )
14576                })?;
14577                for entry in &file_entries {
14578                    if let Some(path) = entry.node.path.as_ref() {
14579                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
14580                    }
14581                }
14582            }
14583        }
14584        load_multiplicity_traversal_nodes(
14585            root,
14586            &gate_source_root,
14587            &mut graph,
14588            &file_handle_by_path,
14589            &mut multiplicity_entries,
14590        )?;
14591    }
14592
14593    let code_lookup = TraversalCodeLookup::new(
14594        &symbol_entries,
14595        &file_entries,
14596        &route_entries,
14597        &multiplicity_entries,
14598    );
14599    load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14600    Ok(graph)
14601}
14602
14603#[cfg(test)]
14604fn build_traversal_graph_source(
14605    root: &Path,
14606    path_hint: &Path,
14607    scope: Option<&str>,
14608) -> Result<TraversalGraphBuild> {
14609    build_traversal_graph_source_with_options(root, path_hint, scope, false)
14610}
14611
14612/// Bounded acquire deadline for the graph-db cross-process write lock. flock
14613/// releases automatically if the holder dies, so a live writer is the only thing
14614/// that can hold this; the bound serializes brief contention and fails closed
14615/// with a clear diagnostic on a wedged holder rather than hanging forever.
14616const GRAPH_DB_WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(15);
14617const GRAPH_DB_WRITE_LOCK_POLL: Duration = Duration::from_millis(50);
14618
14619/// RAII guard for the graph-db advisory write lock; unlocks on drop.
14620pub(crate) struct GraphDbWriteLock {
14621    file: std::fs::File,
14622}
14623
14624impl Drop for GraphDbWriteLock {
14625    fn drop(&mut self) {
14626        let _ = fs4::fs_std::FileExt::unlock(&self.file);
14627    }
14628}
14629
14630pub(crate) fn graph_db_write_lock_path(graph_db: &Path) -> PathBuf {
14631    let stem = graph_db
14632        .file_stem()
14633        .and_then(|stem| stem.to_str())
14634        .unwrap_or("graph");
14635    graph_db.with_file_name(format!("{stem}.write.lock"))
14636}
14637
14638/// Acquire the cross-process advisory write lock guarding graph-db refresh/write
14639/// and snapshot-import against concurrent agent processes. SQLite's busy_timeout
14640/// alone left the write transaction and the snapshot-import rename window racing
14641/// concurrent writers (#gdbwritelock): a refresh could create `-wal`/`-shm`
14642/// sidecars mid-rename and corrupt the freshly imported db, and parallel
14643/// refreshes spuriously failed `database is locked`. This serializes them.
14644pub(crate) fn acquire_graph_db_write_lock(graph_db: &Path) -> Result<GraphDbWriteLock> {
14645    acquire_graph_db_write_lock_with_timeout(graph_db, GRAPH_DB_WRITE_LOCK_TIMEOUT)
14646}
14647
14648pub(crate) fn acquire_graph_db_write_lock_with_timeout(
14649    graph_db: &Path,
14650    timeout: Duration,
14651) -> Result<GraphDbWriteLock> {
14652    use fs4::fs_std::FileExt;
14653
14654    let lock_path = graph_db_write_lock_path(graph_db);
14655    if let Some(parent) = lock_path.parent() {
14656        fs::create_dir_all(parent)
14657            .with_context(|| format!("creating graph-db lock dir: {}", parent.display()))?;
14658    }
14659    let file = std::fs::OpenOptions::new()
14660        .read(true)
14661        .write(true)
14662        .create(true)
14663        .truncate(false)
14664        .open(&lock_path)
14665        .with_context(|| format!("opening graph-db write lock {}", lock_path.display()))?;
14666
14667    let deadline = Instant::now() + timeout;
14668    loop {
14669        match file.try_lock_exclusive() {
14670            Ok(true) => return Ok(GraphDbWriteLock { file }),
14671            Ok(false) => {
14672                if Instant::now() >= deadline {
14673                    bail!(
14674                        "another tsift graph-db writer is active for {} (lock: {}); \
14675                         a concurrent graph-db refresh or snapshot-import is in progress, \
14676                         wait for it to finish before retrying",
14677                        graph_db.display(),
14678                        lock_path.display()
14679                    );
14680                }
14681                std::thread::sleep(GRAPH_DB_WRITE_LOCK_POLL);
14682            }
14683            Err(err) => {
14684                return Err(err).with_context(|| {
14685                    format!("locking graph-db write lock {}", lock_path.display())
14686                });
14687            }
14688        }
14689    }
14690}
14691
14692pub(crate) fn write_traversal_graph_store_with_options(
14693    root: &Path,
14694    path_hint: &Path,
14695    scope: Option<&str>,
14696    session_only: bool,
14697) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14698    let source_graph =
14699        build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14700    let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14701    let graph_db = graph_substrate_db_path(root, scope);
14702    // Serialize the write against concurrent refresh/snapshot-import (#gdbwritelock).
14703    let _write_lock = acquire_graph_db_write_lock(&graph_db)?;
14704    let mut store = SqliteGraphStore::open(&graph_db)?;
14705    let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14706        .ok()
14707        .flatten()
14708        .or_else(|| graph_projection_content_hash(&projection));
14709    let refresh = store.replace_projection_with_version(
14710        scope.unwrap_or("root"),
14711        &projection,
14712        Some(GRAPH_PROJECTION_VERSION),
14713        source_watermark,
14714    )?;
14715    Ok((source_graph, refresh))
14716}
14717
14718pub(crate) fn write_traversal_graph_store(
14719    root: &Path,
14720    path_hint: &Path,
14721    scope: Option<&str>,
14722) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14723    write_traversal_graph_store_with_options(root, path_hint, scope, false)
14724}
14725
14726fn refresh_traversal_graph_store_with_options(
14727    root: &Path,
14728    path_hint: &Path,
14729    scope: Option<&str>,
14730    session_only: bool,
14731) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14732    let (source_graph, refresh) =
14733        write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14734    let graph_db = graph_substrate_db_path(root, scope);
14735    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14736    let mut graph = traversal_graph_from_store(root, &store)?;
14737    graph.warnings = source_graph.warnings;
14738    Ok((graph, refresh))
14739}
14740
14741fn refresh_traversal_graph_store(
14742    root: &Path,
14743    path_hint: &Path,
14744    scope: Option<&str>,
14745) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14746    refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14747}
14748
14749pub(crate) fn build_traversal_graph(
14750    root: &Path,
14751    path_hint: &Path,
14752    scope: Option<&str>,
14753) -> Result<TraversalGraphBuild> {
14754    let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14755    Ok(graph)
14756}
14757
14758fn traversal_query_kind_priority(kind: &str) -> usize {
14759    match kind {
14760        "backlog" => 0,
14761        "job_packet" => 1,
14762        "worker_result" => 2,
14763        "symbol" => 3,
14764        "ast_span" => 4,
14765        "file" => 5,
14766        "route" => 6,
14767        "cargo_package" => 7,
14768        "cargo_workspace" => 8,
14769        "session" => 9,
14770        "semantic_concept" => 10,
14771        "semantic_entity" => 11,
14772        _ => 12,
14773    }
14774}
14775
14776fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14777    let trimmed = query.trim();
14778    if trimmed.is_empty() {
14779        return None;
14780    }
14781    let kind_priority = traversal_query_kind_priority(&node.kind);
14782    if node.handle == trimmed {
14783        return Some((0, kind_priority, node.handle.clone()));
14784    }
14785    if node.path.as_deref() == Some(trimmed) {
14786        let path_priority = if node.kind == "file" {
14787            0
14788        } else {
14789            kind_priority.saturating_add(1)
14790        };
14791        return Some((1, path_priority, node.handle.clone()));
14792    }
14793    let normalized_backlog = trimmed.trim_start_matches('#');
14794    if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14795    {
14796        return Some((2, kind_priority, node.handle.clone()));
14797    }
14798    if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14799        return Some((3, kind_priority, node.handle.clone()));
14800    }
14801    None
14802}
14803
14804fn resolve_traversal_node<'a>(
14805    graph: &'a TraversalGraphBuild,
14806    query: &str,
14807) -> Option<&'a TraversalNode> {
14808    graph
14809        .nodes
14810        .values()
14811        .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14812        .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14813        .map(|(_, node)| node)
14814}
14815
14816fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14817    let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14818    for edge in edges {
14819        adj.entry(edge.from.clone())
14820            .or_default()
14821            .insert(edge.to.clone());
14822        adj.entry(edge.to.clone())
14823            .or_default()
14824            .insert(edge.from.clone());
14825    }
14826    adj.into_iter()
14827        .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14828        .collect()
14829}
14830
14831fn traversal_shortest_handles(
14832    edges: &[TraversalEdge],
14833    from: &str,
14834    to: &str,
14835) -> Option<Vec<String>> {
14836    if from == to {
14837        return Some(vec![from.to_string()]);
14838    }
14839    let adj = traversal_adjacency(edges);
14840    if !adj.contains_key(from) || !adj.contains_key(to) {
14841        return None;
14842    }
14843    let mut visited = BTreeSet::new();
14844    let mut queue = VecDeque::new();
14845    let mut parent = BTreeMap::<String, String>::new();
14846    visited.insert(from.to_string());
14847    queue.push_back(from.to_string());
14848    while let Some(current) = queue.pop_front() {
14849        if let Some(neighbors) = adj.get(&current) {
14850            for neighbor in neighbors {
14851                if visited.insert(neighbor.clone()) {
14852                    parent.insert(neighbor.clone(), current.clone());
14853                    if neighbor == to {
14854                        let mut path = vec![to.to_string()];
14855                        let mut cursor = to.to_string();
14856                        while let Some(prev) = parent.get(&cursor) {
14857                            path.push(prev.clone());
14858                            cursor = prev.clone();
14859                        }
14860                        path.reverse();
14861                        return Some(path);
14862                    }
14863                    queue.push_back(neighbor.clone());
14864                }
14865            }
14866        }
14867    }
14868    None
14869}
14870
14871fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14872    let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14873    for edge in edges {
14874        let neighbor = if edge.from == current {
14875            edge.to.as_str()
14876        } else if edge.to == current {
14877            edge.from.as_str()
14878        } else {
14879            continue;
14880        };
14881        let score = traversal_relation_score(edge, current);
14882        best_score_by_neighbor
14883            .entry(neighbor.to_string())
14884            .and_modify(|best| *best = (*best).max(score))
14885            .or_insert(score);
14886    }
14887    let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14888    ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14889        right_score
14890            .cmp(left_score)
14891            .then_with(|| left_handle.cmp(right_handle))
14892    });
14893    ranked.into_iter().map(|(handle, _)| handle).collect()
14894}
14895
14896fn traversal_neighborhood_handles(
14897    edges: &[TraversalEdge],
14898    origin: &str,
14899    depth: usize,
14900    limit: usize,
14901) -> BTreeSet<String> {
14902    let mut seen = BTreeSet::new();
14903    let mut queue = VecDeque::new();
14904    seen.insert(origin.to_string());
14905    queue.push_back((origin.to_string(), 0usize));
14906    while let Some((current, current_depth)) = queue.pop_front() {
14907        if current_depth >= depth {
14908            continue;
14909        }
14910        for neighbor in traversal_scored_neighbors(edges, &current) {
14911            if limit > 0 && seen.len() >= limit {
14912                return seen;
14913            }
14914            if seen.insert(neighbor.clone()) {
14915                queue.push_back((neighbor, current_depth + 1));
14916            }
14917        }
14918    }
14919    seen
14920}
14921
14922fn traversal_edges_between(
14923    handles: &BTreeSet<String>,
14924    edges: &[TraversalEdge],
14925) -> Vec<TraversalEdge> {
14926    edges
14927        .iter()
14928        .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14929        .cloned()
14930        .collect()
14931}
14932
14933fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14934    let mut result = Vec::new();
14935    for pair in path.windows(2) {
14936        if let Some(edge) = edges.iter().find(|edge| {
14937            (edge.from == pair[0] && edge.to == pair[1])
14938                || (edge.from == pair[1] && edge.to == pair[0])
14939        }) {
14940            result.push(edge.clone());
14941        }
14942    }
14943    result
14944}
14945
14946fn sorted_traversal_nodes<'a>(
14947    nodes: impl IntoIterator<Item = &'a TraversalNode>,
14948) -> Vec<TraversalNode> {
14949    let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14950    nodes.sort_by(|left, right| {
14951        left.kind
14952            .cmp(&right.kind)
14953            .then_with(|| left.label.cmp(&right.label))
14954            .then_with(|| left.path.cmp(&right.path))
14955            .then_with(|| left.handle.cmp(&right.handle))
14956    });
14957    nodes
14958}
14959
14960fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14961    let base = match edge.relation.as_str() {
14962        "mentions" => 100,
14963        "contains" => 80,
14964        "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14965        "contains_embedded_symbol" | "embedded_in_fence" => 77,
14966        "contains_markdown_block"
14967        | "contains_embedded_code"
14968        | "enclosing_module"
14969        | "enclosing_section" => 76,
14970        "calls" => {
14971            if edge.from == origin {
14972                70
14973            } else {
14974                65
14975            }
14976        }
14977        "handled_by" | "handles_route" => 68,
14978        "defines_route" => 62,
14979        "imports" => 62,
14980        "previous_sibling" | "next_sibling" => 54,
14981        "mentions_concept" | "mentions_entity" => 66,
14982        "semantic_relation" => 64,
14983        "tagged_concept" | "related_concept" => 58,
14984        "defines" => {
14985            if edge.from == origin {
14986                60
14987            } else {
14988                55
14989            }
14990        }
14991        _ => 10,
14992    };
14993    base + edge.weight
14994}
14995
14996fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14997    match edge.relation.as_str() {
14998        "mentions" => "matched from backlog/session text".to_string(),
14999        "contains" => "contained in the selected session artifact".to_string(),
15000        "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
15001        "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
15002        "parent" => "parent AST span".to_string(),
15003        "child" => "child AST span".to_string(),
15004        "previous_sibling" => "previous AST sibling".to_string(),
15005        "next_sibling" => "next AST sibling".to_string(),
15006        "contains_markdown_block" => "Markdown section block".to_string(),
15007        "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
15008        "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
15009        "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
15010        "enclosing_module" => "nearest enclosing module".to_string(),
15011        "enclosing_section" => "nearest enclosing Markdown section".to_string(),
15012        "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
15013        "defines" => "file that defines the selected symbol".to_string(),
15014        "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
15015        "defines_route" => "file that declares the selected route".to_string(),
15016        "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
15017        "handled_by" => "route handled by the selected symbol".to_string(),
15018        "handles_route" => "route handled by the selected AST span".to_string(),
15019        "imports" => "import dependency from the selected package".to_string(),
15020        "mentions_concept" => "cached summary concept for the selected source".to_string(),
15021        "mentions_entity" => "cached summary entity for the selected source".to_string(),
15022        "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
15023        "tagged_concept" => "concept label attached to the selected entity".to_string(),
15024        "related_concept" => "co-occurring cached summary concept".to_string(),
15025        "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
15026        "calls" => "caller of the selected symbol".to_string(),
15027        other => format!("connected by {other}"),
15028    }
15029}
15030
15031fn traversal_recommendations(
15032    graph: &TraversalGraphBuild,
15033    origin: Option<&str>,
15034    shortest_path: Option<&[String]>,
15035    limit: usize,
15036) -> Vec<TraversalRecommendation> {
15037    let Some(origin) = origin else {
15038        return Vec::new();
15039    };
15040    let mut recommendations = Vec::new();
15041    let mut seen = BTreeSet::new();
15042
15043    if let Some(path) = shortest_path
15044        && path.len() > 1
15045        && path.first().is_some_and(|handle| handle == origin)
15046        && let Some(next) = graph.nodes.get(&path[1])
15047    {
15048        seen.insert(next.handle.clone());
15049        recommendations.push(TraversalRecommendation {
15050            handle: next.handle.clone(),
15051            kind: next.kind.clone(),
15052            label: next.label.clone(),
15053            reason: "next hop on shortest path".to_string(),
15054            score: 1_000,
15055            expand: next.expand.clone(),
15056        });
15057    }
15058
15059    let mut candidates = graph
15060        .edges
15061        .iter()
15062        .filter_map(|edge| {
15063            let neighbor = if edge.from == origin {
15064                edge.to.as_str()
15065            } else if edge.to == origin {
15066                edge.from.as_str()
15067            } else {
15068                return None;
15069            };
15070            let node = graph.nodes.get(neighbor)?;
15071            Some((traversal_relation_score(edge, origin), edge, node))
15072        })
15073        .collect::<Vec<_>>();
15074    candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
15075        right_score
15076            .cmp(left_score)
15077            .then_with(|| left.kind.cmp(&right.kind))
15078            .then_with(|| left.label.cmp(&right.label))
15079            .then_with(|| left.handle.cmp(&right.handle))
15080    });
15081
15082    let max = if limit == 0 { usize::MAX } else { limit };
15083    for (score, edge, node) in candidates {
15084        if recommendations.len() >= max {
15085            break;
15086        }
15087        if seen.insert(node.handle.clone()) {
15088            recommendations.push(TraversalRecommendation {
15089                handle: node.handle.clone(),
15090                kind: node.kind.clone(),
15091                label: node.label.clone(),
15092                reason: traversal_recommendation_reason(edge, origin),
15093                score,
15094                expand: node.expand.clone(),
15095            });
15096        }
15097    }
15098
15099    recommendations
15100}
15101
15102fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
15103    let scale = nodes.saturating_add(edges);
15104    if scale <= 80 {
15105        ExplorationBudget {
15106            project_size: "small".to_string(),
15107            max_source_windows: 8,
15108            lines_per_window: 96,
15109            relationship_limit: 40,
15110        }
15111    } else if scale <= 800 {
15112        ExplorationBudget {
15113            project_size: "medium".to_string(),
15114            max_source_windows: 6,
15115            lines_per_window: 80,
15116            relationship_limit: 32,
15117        }
15118    } else {
15119        ExplorationBudget {
15120            project_size: "large".to_string(),
15121            max_source_windows: 4,
15122            lines_per_window: 64,
15123            relationship_limit: 24,
15124        }
15125    }
15126}
15127
15128fn exploration_node_label(node: &TraversalNode) -> String {
15129    format!("{}:{}", node.kind, node.label)
15130}
15131
15132fn exploration_source_window_for_node(
15133    root: &Path,
15134    node: &TraversalNode,
15135    budget: &ExplorationBudget,
15136) -> Option<ExplorationSourceWindow> {
15137    let file = node.path.as_ref()?;
15138    let anchor = node
15139        .line
15140        .and_then(|line| usize::try_from(line).ok())
15141        .and_then(|line| line.checked_add(1))
15142        .unwrap_or(1);
15143    let context_before = budget.lines_per_window / 3;
15144    let start = anchor.saturating_sub(context_before).max(1);
15145    let end = start
15146        .saturating_add(budget.lines_per_window)
15147        .saturating_sub(1);
15148    let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
15149    Some(ExplorationSourceWindow {
15150        handle,
15151        file: file.clone(),
15152        start,
15153        end,
15154        reason: format!("cluster around {}", exploration_node_label(node)),
15155        expand: source_read_command(root, file, start, budget.lines_per_window),
15156    })
15157}
15158
15159fn build_exploration_packet(
15160    root: &Path,
15161    totals: &TraversalTotals,
15162    selected_nodes: &[TraversalNode],
15163    selected_edges: &[TraversalEdge],
15164) -> ExplorationPacket {
15165    let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
15166    let node_by_handle = selected_nodes
15167        .iter()
15168        .map(|node| (node.handle.as_str(), node))
15169        .collect::<BTreeMap<_, _>>();
15170    let relationship_map = selected_edges
15171        .iter()
15172        .take(budget.relationship_limit)
15173        .filter_map(|edge| {
15174            let from = node_by_handle.get(edge.from.as_str())?;
15175            let to = node_by_handle.get(edge.to.as_str())?;
15176            Some(ExplorationRelation {
15177                from: exploration_node_label(from),
15178                relation: edge.relation.clone(),
15179                to: exploration_node_label(to),
15180                label: edge.label.clone(),
15181            })
15182        })
15183        .collect::<Vec<_>>();
15184
15185    let mut seen_windows = BTreeSet::new();
15186    let mut source_windows = Vec::new();
15187    for node in selected_nodes {
15188        if source_windows.len() >= budget.max_source_windows {
15189            break;
15190        }
15191        let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
15192            continue;
15193        };
15194        let key = (window.file.clone(), window.start, window.end);
15195        if seen_windows.insert(key) {
15196            source_windows.push(window);
15197        }
15198    }
15199
15200    ExplorationPacket {
15201        budget,
15202        relationship_map,
15203        source_windows,
15204        worker_context: Vec::new(),
15205        no_reread_guidance:
15206            "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
15207                .to_string(),
15208    }
15209}
15210
15211pub(crate) fn traversal_report(
15212    root: &Path,
15213    scope: Option<&str>,
15214    graph: TraversalGraphBuild,
15215    query: Option<&str>,
15216    target: Option<&str>,
15217    depth: usize,
15218    limit: usize,
15219) -> Result<TraversalReport> {
15220    let totals = TraversalTotals {
15221        nodes: graph.nodes.len(),
15222        edges: graph.edges.len(),
15223    };
15224    let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
15225    let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
15226    if let Some(query) = query
15227        && origin_node.is_none()
15228    {
15229        bail!("traversal node not found: {}", query);
15230    }
15231    if let Some(target) = target
15232        && target_node.is_none()
15233    {
15234        bail!("traversal target not found: {}", target);
15235    }
15236
15237    let (mode, selected_nodes, selected_edges, shortest_path) =
15238        if let (Some(origin), Some(target)) = (origin_node, target_node) {
15239            if let Some(handles) =
15240                traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
15241            {
15242                let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
15243                let nodes = handles
15244                    .iter()
15245                    .filter_map(|handle| graph.nodes.get(handle).cloned())
15246                    .collect::<Vec<_>>();
15247                let edges = traversal_path_edges(&handles, &graph.edges);
15248                let path = TraversalPathReport {
15249                    from: origin.clone(),
15250                    to: target.clone(),
15251                    hops: handles.len().saturating_sub(1),
15252                    nodes: nodes.clone(),
15253                    edges: edges.clone(),
15254                };
15255                (
15256                    "path".to_string(),
15257                    nodes,
15258                    traversal_edges_between(&handle_set, &graph.edges),
15259                    Some(path),
15260                )
15261            } else {
15262                (
15263                    "path".to_string(),
15264                    vec![origin.clone(), target.clone()],
15265                    Vec::new(),
15266                    None,
15267                )
15268            }
15269        } else if let Some(origin) = origin_node {
15270            let handles =
15271                traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
15272            let nodes =
15273                sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
15274            let edges = traversal_edges_between(&handles, &graph.edges);
15275            ("neighborhood".to_string(), nodes, edges, None)
15276        } else {
15277            let mut nodes = sorted_traversal_nodes(graph.nodes.values());
15278            let truncated_nodes = limit > 0 && nodes.len() > limit;
15279            if truncated_nodes {
15280                nodes.truncate(limit);
15281            }
15282            let handles = nodes
15283                .iter()
15284                .map(|node| node.handle.clone())
15285                .collect::<BTreeSet<_>>();
15286            let mut edges = traversal_edges_between(&handles, &graph.edges);
15287            let truncated_edges = limit > 0 && edges.len() > limit;
15288            if truncated_edges {
15289                edges.truncate(limit);
15290            }
15291            ("export".to_string(), nodes, edges, None)
15292        };
15293
15294    let shortest_handles = shortest_path.as_ref().map(|path| {
15295        path.nodes
15296            .iter()
15297            .map(|node| node.handle.clone())
15298            .collect::<Vec<_>>()
15299    });
15300    let recommendations = traversal_recommendations(
15301        &graph,
15302        origin_node.map(|node| node.handle.as_str()),
15303        shortest_handles.as_deref(),
15304        if limit == 0 { 10 } else { limit.min(10) },
15305    );
15306    let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
15307    let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
15308
15309    Ok(TraversalReport {
15310        root: root.to_string_lossy().to_string(),
15311        scope: scope.map(str::to_string),
15312        mode,
15313        totals,
15314        query: query.map(str::to_string),
15315        target: target.map(str::to_string),
15316        nodes: selected_nodes,
15317        edges: selected_edges,
15318        shortest_path,
15319        recommendations,
15320        exploration,
15321        truncated,
15322        warnings: graph.warnings,
15323    })
15324}
15325
15326fn html_escape(input: &str) -> String {
15327    input
15328        .replace('&', "&amp;")
15329        .replace('<', "&lt;")
15330        .replace('>', "&gt;")
15331        .replace('"', "&quot;")
15332        .replace('\'', "&#39;")
15333}
15334
15335pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
15336    let json = serde_json::to_string(report)?.replace("</", "<\\/");
15337    let mut html = String::new();
15338    html.push_str(
15339        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
15340    );
15341    html.push_str(
15342        r#"<style>
15343:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
15344@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
15345*{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}}
15346</style>"#,
15347    );
15348    html.push_str("</head><body>");
15349    html.push_str("<div class=\"page\">");
15350    html.push_str(&format!(
15351        "<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>",
15352        html_escape(&report.mode),
15353        report.nodes.len(),
15354        report.totals.nodes,
15355        report.edges.len(),
15356        report.totals.edges
15357    ));
15358    html.push_str(
15359        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>"#,
15360    );
15361    html.push_str("<script id=\"graph-data\" type=\"application/json\">");
15362    html.push_str(&json);
15363    html.push_str(
15364        r##"</script><script>
15365const report = JSON.parse(document.getElementById("graph-data").textContent);
15366const svg = document.getElementById("graph-canvas");
15367const list = document.getElementById("node-list");
15368const selected = document.getElementById("selected");
15369const filter = document.getElementById("filter");
15370const legend = document.getElementById("legend");
15371const nodes = report.nodes.map((node, index) => ({...node, index}));
15372const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
15373const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
15374const colorByKind = new Map([
15375  ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
15376  ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
15377  ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
15378  ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
15379]);
15380function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
15381function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
15382function text(value){ return value == null ? "" : String(value); }
15383function matches(node, query){
15384  if (!query) return true;
15385  const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
15386  return haystack.includes(query);
15387}
15388function layout(){
15389  const rect = svg.getBoundingClientRect();
15390  const width = rect.width || 900;
15391  const height = rect.height || 650;
15392  const cx = width / 2;
15393  const cy = height / 2;
15394  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15395  const counts = new Map();
15396  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
15397  const offsets = new Map();
15398  for (const node of nodes) {
15399    const group = kinds.indexOf(node.kind);
15400    const index = offsets.get(node.kind) || 0;
15401    offsets.set(node.kind, index + 1);
15402    const groupCount = counts.get(node.kind) || 1;
15403    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
15404    const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
15405    node.x = cx + Math.cos(angle) * ring;
15406    node.y = cy + Math.sin(angle) * ring;
15407  }
15408}
15409function draw(){
15410  const query = filter.value.trim().toLowerCase();
15411  const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
15412  svg.innerHTML = "";
15413  for (const edge of edges) {
15414    if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
15415    const from = nodeByHandle.get(edge.from);
15416    const to = nodeByHandle.get(edge.to);
15417    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
15418    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
15419    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
15420    line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
15421    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
15422    svg.appendChild(line);
15423  }
15424  for (const node of nodes) {
15425    if (!visible.has(node.handle)) continue;
15426    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
15427    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
15428    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
15429    circle.setAttribute("fill", color(node.kind));
15430    circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
15431    circle.addEventListener("click", () => selectNode(node));
15432    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
15433    svg.appendChild(circle);
15434    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
15435    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
15436    label.setAttribute("class", "node-label");
15437    label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
15438    svg.appendChild(label);
15439  }
15440  renderList(query);
15441}
15442function renderLegend(){
15443  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15444  legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">&#9679;</b> ${kind}</span>`).join("");
15445}
15446function renderList(query){
15447  const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
15448  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("");
15449  for (const row of list.querySelectorAll(".row")) {
15450    row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
15451  }
15452}
15453function selectNode(node){
15454  const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
15455  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>`;
15456}
15457function escapeHtml(value){
15458  return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));
15459}
15460filter.addEventListener("input", draw);
15461window.addEventListener("resize", () => { layout(); draw(); });
15462renderLegend();
15463layout();
15464draw();
15465if (nodes.length) selectNode(nodes[0]);
15466</script></div></body></html>"##,
15467    );
15468    Ok(html)
15469}
15470
15471fn semantic_related_report_from_store(
15472    root: &Path,
15473    scope: Option<&str>,
15474    query: &str,
15475    limit: usize,
15476    kind: SemanticRelatedKind,
15477    store: &impl GraphStore,
15478) -> Result<SemanticRelatedReport> {
15479    if query.trim().is_empty() {
15480        bail!("semantic query cannot be empty");
15481    }
15482
15483    let query_embedding = semantic_embedding(query);
15484    let node_kinds: &[&str] = match kind {
15485        SemanticRelatedKind::Concept => &["semantic_concept"],
15486        SemanticRelatedKind::Entity => &["semantic_entity"],
15487        SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
15488    };
15489
15490    let items = store
15491        .semantic_top_candidates(&query_embedding, node_kinds, limit)?
15492        .into_iter()
15493        .map(|candidate| {
15494            let node = candidate.node;
15495            SemanticRelatedItem {
15496                handle: node
15497                    .properties
15498                    .get("handle")
15499                    .cloned()
15500                    .unwrap_or_else(|| node.id.clone()),
15501                kind: node.kind,
15502                label: node.label,
15503                score: candidate.score,
15504                file_path: node
15505                    .properties
15506                    .get("source_file")
15507                    .or_else(|| node.properties.get("path"))
15508                    .cloned(),
15509                source_symbol: node.properties.get("source_symbol").cloned(),
15510                detail: node
15511                    .properties
15512                    .get("description")
15513                    .or_else(|| node.properties.get("detail"))
15514                    .cloned(),
15515                expand: node
15516                    .properties
15517                    .get("expand")
15518                    .cloned()
15519                    .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
15520            }
15521        })
15522        .collect::<Vec<_>>();
15523
15524    let mut warnings = Vec::new();
15525    if items.is_empty() {
15526        warnings.push(
15527            "no semantic graph rows found; run `tsift summarize --extract <path>` first"
15528                .to_string(),
15529        );
15530    }
15531
15532    Ok(SemanticRelatedReport {
15533        root: root.to_string_lossy().to_string(),
15534        scope: scope.map(str::to_string),
15535        query: query.to_string(),
15536        embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
15537        count: items.len(),
15538        items,
15539        warnings,
15540    })
15541}
15542
15543fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
15544    Ok(store.nodes_by_kind("semantic_concept")?.len()
15545        + store.nodes_by_kind("semantic_entity")?.len())
15546}
15547
15548fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
15549    if limit == 0 {
15550        return 0;
15551    }
15552    limit.saturating_mul(4).clamp(
15553        GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
15554        GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
15555    )
15556}
15557
15558fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
15559    if limit == 0 {
15560        return usize::MAX;
15561    }
15562    limit.saturating_mul(3).max(limit).max(seed_count)
15563}
15564
15565fn graph_db_semantic_seeded_neighborhood(
15566    store: &impl GraphStore,
15567    seed_ids: &[String],
15568    depth: usize,
15569    limit: usize,
15570) -> Result<GraphDbSemanticSeededSubgraph> {
15571    let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
15572    let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
15573    let mut diagnostics = vec![
15574        "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
15575        "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(),
15576        format!(
15577            "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
15578            if edge_scan_cap == 0 {
15579                "unbounded".to_string()
15580            } else {
15581                edge_scan_cap.to_string()
15582            },
15583            if node_discovery_cap == usize::MAX {
15584                "unbounded".to_string()
15585            } else {
15586                node_discovery_cap.to_string()
15587            }
15588        ),
15589    ];
15590
15591    let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
15592        .with_edge_scan_cap(edge_scan_cap)
15593        .with_node_discovery_cap(node_discovery_cap);
15594    let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
15595
15596    for seed_id in &result.missing_seed_ids {
15597        diagnostics.push(format!(
15598            "semantic seed {seed_id} was not present in the graph store"
15599        ));
15600    }
15601
15602    if result.skipped_by_edge_cap > 0 {
15603        diagnostics.push(format!(
15604            "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
15605            result.skipped_by_edge_cap
15606        ));
15607    }
15608    if result.skipped_by_node_cap > 0 {
15609        diagnostics.push(format!(
15610            "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
15611            result.skipped_by_node_cap
15612        ));
15613    }
15614
15615    if result.truncated {
15616        diagnostics.push(format!(
15617            "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
15618            result.total_discovered
15619        ));
15620    }
15621
15622    Ok(GraphDbSemanticSeededSubgraph {
15623        nodes: result.nodes,
15624        edges: result.edges,
15625        truncated: result.truncated,
15626        diagnostics,
15627    })
15628}
15629
15630#[allow(clippy::too_many_arguments)]
15631fn cmd_semantic_related(
15632    query: &str,
15633    path: &Path,
15634    scope: Option<&str>,
15635    limit: usize,
15636    kind: SemanticRelatedKind,
15637    json_output: bool,
15638    compact: bool,
15639    pretty: bool,
15640    terse: bool,
15641    schema: bool,
15642    profile: Option<String>,
15643) -> Result<()> {
15644    let root = lint::resolve_project_root_or_canonical_path(path)?;
15645    write_traversal_graph_store(&root, path, scope)?;
15646    let graph_db = graph_substrate_db_path(&root, scope);
15647    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15648    let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15649    if let Some(recovery) = store.read_only_recovery() {
15650        report
15651            .warnings
15652            .push(graph_db_read_recovery_diagnostic(recovery));
15653    }
15654    if let Some(note) =
15655        profile_preference_note(profile.as_deref(), tsift_local_model::ModelRole::Embed)
15656    {
15657        report.warnings.push(note);
15658    }
15659
15660    if json_output {
15661        println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15662    } else if compact {
15663        for item in &report.items {
15664            println!(
15665                "{:.3}\t{}\t{}\t{}",
15666                item.score, item.kind, item.label, item.handle
15667            );
15668        }
15669        for warning in &report.warnings {
15670            eprintln!("warning: {warning}");
15671        }
15672    } else {
15673        println!(
15674            "Related semantic graph rows for {:?} ({})",
15675            report.query, report.embedding_model
15676        );
15677        for item in &report.items {
15678            println!(
15679                "  {:.3} [{}] {} ({})",
15680                item.score, item.kind, item.label, item.handle
15681            );
15682            if let Some(detail) = &item.detail {
15683                println!("      {}", detail);
15684            }
15685            if let Some(file_path) = &item.file_path {
15686                println!("      file: {}", file_path);
15687            }
15688            println!("      expand: {}", item.expand);
15689        }
15690        for warning in &report.warnings {
15691            eprintln!("warning: {warning}");
15692        }
15693    }
15694
15695    Ok(())
15696}
15697
15698/// Resolve a `--profile` CLI value into a one-line note for the response
15699/// envelope. Records the caller's intent and the resolved profile id, so
15700/// downstream readers see what would have been used even while the actual
15701/// provider seam still falls back to the hash profile (#gctrl2).
15702fn profile_preference_note(
15703    profile: Option<&str>,
15704    role: tsift_local_model::ModelRole,
15705) -> Option<String> {
15706    let preference = tsift_local_model::ProfilePreference::from_cli(profile);
15707    if matches!(preference, tsift_local_model::ProfilePreference::Auto) {
15708        return None;
15709    }
15710    let probe = tsift_local_model::probe_nvidia_smi();
15711    let resolution = tsift_local_model::resolve_profile_preference(&preference, role, &probe);
15712    Some(format!(
15713        "profile preference {} -> {} ({})",
15714        preference.describe(),
15715        resolution.profile.id,
15716        resolution.reason
15717    ))
15718}
15719
15720#[derive(Serialize)]
15721struct SourceLinePreview {
15722    line: usize,
15723    text: String,
15724}
15725
15726#[derive(Serialize)]
15727pub(crate) struct SourceRangePreview {
15728    start: usize,
15729    end: usize,
15730    total_lines: usize,
15731    truncated_before: bool,
15732    truncated_after: bool,
15733}
15734
15735#[derive(Serialize)]
15736struct SourceExpandCommands {
15737    #[serde(skip_serializing_if = "Option::is_none")]
15738    before: Option<String>,
15739    #[serde(skip_serializing_if = "Option::is_none")]
15740    after: Option<String>,
15741    #[serde(skip_serializing_if = "Option::is_none")]
15742    body: Option<String>,
15743    file: String,
15744    #[serde(skip_serializing_if = "Option::is_none")]
15745    markdown_ast: Option<String>,
15746}
15747
15748#[derive(Serialize)]
15749struct SourceSymbolRef {
15750    handle: String,
15751    name: String,
15752    kind: String,
15753    language: String,
15754    file: String,
15755    line: usize,
15756    #[serde(skip_serializing_if = "Option::is_none")]
15757    end_line: Option<usize>,
15758    #[serde(skip_serializing_if = "Option::is_none")]
15759    signature: Option<String>,
15760    #[serde(skip_serializing_if = "Option::is_none")]
15761    span: Option<AstSpanPreview>,
15762    expand: String,
15763}
15764
15765#[derive(Serialize)]
15766struct SourceSummaryRef {
15767    handle: String,
15768    symbol_name: String,
15769    file_path: String,
15770    summary: String,
15771    expand: String,
15772}
15773
15774#[derive(Serialize)]
15775struct SourceReadReport {
15776    handle: String,
15777    root: String,
15778    file: String,
15779    range: SourceRangePreview,
15780    preview: Vec<SourceLinePreview>,
15781    symbols: Vec<SourceSymbolRef>,
15782    summaries: Vec<SourceSummaryRef>,
15783    #[serde(skip_serializing_if = "Option::is_none")]
15784    markdown: Option<SourceReadMarkdownProjection>,
15785    expand: SourceExpandCommands,
15786    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15787    warnings: Vec<String>,
15788}
15789
15790#[derive(Serialize)]
15791struct SourceReadAstExpandCommands {
15792    window: String,
15793    file_window: String,
15794    #[serde(skip_serializing_if = "Option::is_none")]
15795    markdown_ast: Option<String>,
15796}
15797
15798#[derive(Serialize)]
15799struct SourceReadAstReport {
15800    handle: String,
15801    root: String,
15802    file: String,
15803    range: SourceRangePreview,
15804    symbols: Vec<SourceSymbolRef>,
15805    summaries: Vec<SourceSummaryRef>,
15806    #[serde(skip_serializing_if = "Option::is_none")]
15807    markdown: Option<SourceReadMarkdownProjection>,
15808    expand: SourceReadAstExpandCommands,
15809    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15810    warnings: Vec<String>,
15811}
15812
15813#[derive(Serialize)]
15814struct SymbolReadTarget {
15815    handle: String,
15816    name: String,
15817    kind: String,
15818    language: String,
15819    file: String,
15820    line: usize,
15821    #[serde(skip_serializing_if = "Option::is_none")]
15822    end_line: Option<usize>,
15823    #[serde(skip_serializing_if = "Option::is_none")]
15824    signature: Option<String>,
15825    #[serde(skip_serializing_if = "Option::is_none")]
15826    parent_module: Option<String>,
15827    #[serde(skip_serializing_if = "Option::is_none")]
15828    visibility: Option<String>,
15829    #[serde(skip_serializing_if = "Option::is_none")]
15830    span: Option<AstSpanPreview>,
15831}
15832
15833#[derive(Serialize)]
15834struct SymbolReadExpandCommands {
15835    source_window: String,
15836    #[serde(skip_serializing_if = "Option::is_none")]
15837    body: Option<String>,
15838    file: String,
15839    explain: String,
15840    callers: String,
15841    callees: String,
15842    #[serde(skip_serializing_if = "Option::is_none")]
15843    markdown_ast: Option<String>,
15844}
15845
15846#[derive(Serialize)]
15847struct SymbolReadReport {
15848    handle: String,
15849    root: String,
15850    query: String,
15851    symbol: SymbolReadTarget,
15852    range: SourceRangePreview,
15853    body: Vec<SourceLinePreview>,
15854    child_symbols: Vec<SourceSymbolRef>,
15855    summaries: Vec<SourceSummaryRef>,
15856    expand: SymbolReadExpandCommands,
15857    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15858    warnings: Vec<String>,
15859}
15860
15861#[derive(Clone)]
15862pub(crate) struct MarkdownAstRawNode {
15863    handle: String,
15864    span_handle: String,
15865    name: String,
15866    kind: String,
15867    block_kind: String,
15868    node_kind: String,
15869    start_byte: usize,
15870    end_byte: usize,
15871    body_start_byte: Option<usize>,
15872    body_end_byte: Option<usize>,
15873}
15874
15875#[derive(Clone)]
15876pub(crate) struct MarkdownAstProjection {
15877    source_hash: String,
15878    nodes: Vec<MarkdownAstRawNode>,
15879    parse_duration_micros: u128,
15880    cache_hit: bool,
15881}
15882
15883#[derive(Clone)]
15884struct MarkdownAstCacheEntry {
15885    source_hash: String,
15886    nodes: Vec<MarkdownAstRawNode>,
15887    parse_duration_micros: u128,
15888}
15889
15890static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15891    OnceLock::new();
15892
15893#[derive(Serialize, Clone)]
15894struct MarkdownAstNodeMetadata {
15895    #[serde(skip_serializing_if = "Option::is_none")]
15896    heading_level: Option<usize>,
15897    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15898    section_path: Vec<String>,
15899    #[serde(skip_serializing_if = "Option::is_none")]
15900    section_handle: Option<String>,
15901    #[serde(skip_serializing_if = "Option::is_none")]
15902    list_depth: Option<usize>,
15903    #[serde(skip_serializing_if = "Option::is_none")]
15904    list_marker: Option<String>,
15905    #[serde(skip_serializing_if = "Option::is_none")]
15906    list_order: Option<usize>,
15907    #[serde(skip_serializing_if = "Option::is_none")]
15908    fence_language: Option<String>,
15909    #[serde(skip_serializing_if = "Option::is_none")]
15910    fence_marker: Option<String>,
15911    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15912    embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15913}
15914
15915#[derive(Serialize, Clone)]
15916struct MarkdownAstNodeExpand {
15917    source_window: String,
15918    source_body: String,
15919    symbol_read: String,
15920    edit_intents: String,
15921}
15922
15923#[derive(Serialize, Clone)]
15924struct MarkdownAstCacheReport {
15925    source_hash: String,
15926    cache_hit: bool,
15927    parse_duration_micros: u128,
15928    node_count: usize,
15929    section_count: usize,
15930    list_item_count: usize,
15931    code_block_count: usize,
15932}
15933
15934#[derive(Serialize, Clone)]
15935struct MarkdownAstPhaseTiming {
15936    name: String,
15937    duration_micros: u128,
15938    detail: String,
15939}
15940
15941#[derive(Serialize, Clone)]
15942struct MarkdownAstOutlineEntry {
15943    handle: String,
15944    span_handle: String,
15945    name: String,
15946    kind: String,
15947    block_kind: String,
15948    line: usize,
15949    end_line: usize,
15950    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15951    section_path: Vec<String>,
15952    child_count: usize,
15953    expand: String,
15954}
15955
15956#[derive(Serialize, Clone)]
15957struct MarkdownAstProjectionPreview {
15958    mode: String,
15959    total_nodes: usize,
15960    returned_nodes: usize,
15961    omitted_nodes: usize,
15962    selected_node: Option<String>,
15963    cache: MarkdownAstCacheReport,
15964    outline: Vec<MarkdownAstOutlineEntry>,
15965    phase_timings: Vec<MarkdownAstPhaseTiming>,
15966}
15967
15968#[derive(Serialize)]
15969struct SourceReadMarkdownProjection {
15970    handle: String,
15971    mode: String,
15972    total_nodes: usize,
15973    visible_nodes: usize,
15974    outline: Vec<MarkdownAstOutlineEntry>,
15975    expand: String,
15976}
15977
15978#[derive(Serialize, Clone)]
15979struct SourceByteRangePreview {
15980    start: usize,
15981    end: usize,
15982}
15983
15984#[derive(Serialize, Clone)]
15985struct MarkdownAstNode {
15986    handle: String,
15987    span_handle: String,
15988    name: String,
15989    kind: String,
15990    block_kind: String,
15991    node_kind: String,
15992    line: usize,
15993    end_line: usize,
15994    byte_span: SourceByteRangePreview,
15995    #[serde(skip_serializing_if = "Option::is_none")]
15996    body_byte_span: Option<SourceByteRangePreview>,
15997    parent_handle: Option<String>,
15998    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15999    child_handles: Vec<String>,
16000    metadata: MarkdownAstNodeMetadata,
16001    expand: MarkdownAstNodeExpand,
16002}
16003
16004#[derive(Serialize)]
16005struct MarkdownAstExpandCommands {
16006    file: String,
16007    source_read: String,
16008    edit_intents: String,
16009}
16010
16011#[derive(Serialize)]
16012struct MarkdownAstReport {
16013    handle: String,
16014    root: String,
16015    file: String,
16016    range: SourceRangePreview,
16017    projection: MarkdownAstProjectionPreview,
16018    nodes: Vec<MarkdownAstNode>,
16019    expand: MarkdownAstExpandCommands,
16020    #[serde(skip_serializing_if = "Vec::is_empty", default)]
16021    warnings: Vec<String>,
16022}
16023
16024pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
16025    let candidate = if file.is_absolute() {
16026        file.to_path_buf()
16027    } else {
16028        root.join(file)
16029    };
16030    let canonical = candidate
16031        .canonicalize()
16032        .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
16033    if !canonical.is_file() {
16034        bail!("source file is not a regular file: {}", canonical.display());
16035    }
16036    let canonical_root = root
16037        .canonicalize()
16038        .with_context(|| format!("canonicalizing project root {}", root.display()))?;
16039    if !canonical.starts_with(&canonical_root) {
16040        bail!(
16041            "source file {} is outside project root {}",
16042            canonical.display(),
16043            canonical_root.display()
16044        );
16045    }
16046    Ok(canonical)
16047}
16048
16049pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
16050    source_read_window_command(root, file, start, lines)
16051}
16052
16053pub(crate) fn source_read_window_command(
16054    root: &Path,
16055    file: &str,
16056    start: usize,
16057    lines: usize,
16058) -> String {
16059    format!(
16060        "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
16061        shell_quote(file),
16062        shell_quote(&root.to_string_lossy()),
16063        start,
16064        lines
16065    )
16066}
16067
16068pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
16069    format!(
16070        "tsift --envelope source-read {} --path {} --budget normal",
16071        shell_quote(file),
16072        shell_quote(&root.to_string_lossy())
16073    )
16074}
16075
16076pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
16077    format!(
16078        "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
16079        shell_quote(symbol),
16080        shell_quote(&root.to_string_lossy()),
16081        shell_quote(file)
16082    )
16083}
16084
16085fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
16086    format!(
16087        "tsift --envelope explain {} --path {} --budget normal",
16088        shell_quote(symbol),
16089        shell_quote(&root.to_string_lossy())
16090    )
16091}
16092
16093fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
16094    format!(
16095        "tsift graph {} --path {} --{} --json",
16096        shell_quote(symbol),
16097        shell_quote(&root.to_string_lossy()),
16098        relation
16099    )
16100}
16101
16102fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
16103    format!(
16104        "tsift summarize {} --path {} --json",
16105        shell_quote(symbol),
16106        shell_quote(&root.to_string_lossy())
16107    )
16108}
16109
16110pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
16111    let mut command = format!(
16112        "tsift --envelope markdown-ast {} --path {} --budget normal",
16113        shell_quote(file),
16114        shell_quote(&root.to_string_lossy())
16115    );
16116    if let Some(node) = node {
16117        command.push_str(" --node ");
16118        command.push_str(&shell_quote(node));
16119    }
16120    command
16121}
16122
16123fn markdown_edit_intents_command(root: &Path) -> String {
16124    format!(
16125        "tsift --envelope edit-intents --path {} --budget normal",
16126        shell_quote(&root.to_string_lossy())
16127    )
16128}
16129
16130pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
16131    usize::try_from(symbol.line)
16132        .ok()
16133        .and_then(|line| line.checked_add(1))
16134        .unwrap_or(1)
16135}
16136
16137fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
16138    symbol
16139        .end_line
16140        .and_then(|line| usize::try_from(line).ok())
16141        .and_then(|line| line.checked_add(1))
16142}
16143
16144fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
16145    value.and_then(|byte| usize::try_from(byte).ok())
16146}
16147
16148fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
16149    let byte = byte.min(source.len());
16150    source[..byte]
16151        .iter()
16152        .filter(|value| **value == b'\n')
16153        .count()
16154        .saturating_add(1)
16155}
16156
16157fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
16158    source_line_for_byte(source, end_byte.saturating_sub(1))
16159}
16160
16161fn ast_span_handle(
16162    file: &str,
16163    name: &str,
16164    kind: &str,
16165    start_byte: usize,
16166    end_byte: usize,
16167) -> String {
16168    stable_handle(
16169        "span",
16170        &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
16171    )
16172}
16173
16174pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
16175    Some((
16176        symbol_span_byte(symbol.start_byte)?,
16177        symbol_span_byte(symbol.end_byte)?,
16178    ))
16179}
16180
16181pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
16182    Some((
16183        symbol_span_byte(symbol.start_byte)?,
16184        symbol_span_byte(symbol.end_byte)?,
16185    ))
16186}
16187
16188pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
16189    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16190    Some(ast_span_handle(
16191        &symbol.file,
16192        &symbol.name,
16193        &symbol.kind,
16194        start_byte,
16195        end_byte,
16196    ))
16197}
16198
16199fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
16200    left.file == right.file
16201        && left.name == right.name
16202        && left.kind == right.kind
16203        && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
16204}
16205
16206fn stored_symbol_parent_span_handle(
16207    symbol: &index::StoredSymbol,
16208    symbols: &[index::StoredSymbol],
16209) -> Option<String> {
16210    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16211    symbols
16212        .iter()
16213        .filter(|candidate| {
16214            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16215                return false;
16216            }
16217            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16218            else {
16219                return false;
16220            };
16221            candidate_start <= start_byte && candidate_end >= end_byte
16222        })
16223        .min_by_key(|candidate| {
16224            stored_symbol_span_bounds(candidate)
16225                .map(|(start, end)| end.saturating_sub(start))
16226                .unwrap_or(usize::MAX)
16227        })
16228        .and_then(stored_symbol_span_handle)
16229}
16230
16231fn stored_symbol_child_span_handles(
16232    symbol: &index::StoredSymbol,
16233    symbols: &[index::StoredSymbol],
16234    limit: usize,
16235) -> Vec<String> {
16236    let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
16237        return Vec::new();
16238    };
16239    symbols
16240        .iter()
16241        .filter(|candidate| {
16242            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16243                return false;
16244            }
16245            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16246            else {
16247                return false;
16248            };
16249            candidate_start >= start_byte && candidate_end <= end_byte
16250        })
16251        .take(limit)
16252        .filter_map(stored_symbol_span_handle)
16253        .collect()
16254}
16255
16256fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
16257    let start = start_byte.min(source.len());
16258    let line_end = source[start..]
16259        .iter()
16260        .position(|value| *value == b'\n')
16261        .map(|pos| start + pos)
16262        .unwrap_or(source.len());
16263    let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
16264    let marker = line.trim_start();
16265    let level = marker.chars().take_while(|ch| *ch == '#').count();
16266    (1..=6).contains(&level).then_some(level)
16267}
16268
16269fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
16270    let start = start_byte.min(source.len());
16271    let line_start = source[..start]
16272        .iter()
16273        .rposition(|value| *value == b'\n')
16274        .map(|pos| pos + 1)
16275        .unwrap_or(0);
16276    source[line_start..start]
16277        .iter()
16278        .map(|byte| match byte {
16279            b'\t' => 4,
16280            b' ' => 1,
16281            _ => 0,
16282        })
16283        .sum::<usize>()
16284        / 2
16285}
16286
16287fn markdown_enclosing_heading_symbols<'a>(
16288    file: &str,
16289    start_byte: usize,
16290    end_byte: usize,
16291    symbols: &'a [index::StoredSymbol],
16292) -> Vec<&'a index::StoredSymbol> {
16293    let mut headings = symbols
16294        .iter()
16295        .filter(|candidate| candidate.file == file && candidate.kind == "heading")
16296        .filter(|candidate| {
16297            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16298            else {
16299                return false;
16300            };
16301            candidate_start <= start_byte && candidate_end >= end_byte
16302        })
16303        .collect::<Vec<_>>();
16304    headings.sort_by(|left, right| {
16305        stored_symbol_span_bounds(left)
16306            .map(|(start, _)| start)
16307            .unwrap_or(usize::MAX)
16308            .cmp(
16309                &stored_symbol_span_bounds(right)
16310                    .map(|(start, _)| start)
16311                    .unwrap_or(usize::MAX),
16312            )
16313            .then(left.name.cmp(&right.name))
16314    });
16315    headings
16316}
16317
16318fn markdown_stored_symbol_metadata(
16319    symbol: &index::StoredSymbol,
16320    source: &[u8],
16321    symbols: &[index::StoredSymbol],
16322) -> Option<MarkdownSpanMetadata> {
16323    if symbol.language != "markdown" {
16324        return None;
16325    }
16326    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16327    let section_symbols =
16328        markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
16329    let section_path = section_symbols
16330        .iter()
16331        .map(|heading| heading.name.clone())
16332        .collect::<Vec<_>>();
16333    let section_handle = section_symbols
16334        .last()
16335        .and_then(|heading| stored_symbol_span_handle(heading));
16336    let heading_level = (symbol.kind == "heading")
16337        .then(|| markdown_heading_level(source, start_byte))
16338        .flatten();
16339    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16340    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16341    let embedded_symbols = if symbol.kind == "code_block" {
16342        markdown_embedded_symbols(
16343            &symbol.file,
16344            source,
16345            symbol_span_byte(symbol.body_start_byte),
16346            symbol_span_byte(symbol.body_end_byte),
16347            fence_language.as_deref(),
16348        )
16349    } else {
16350        Vec::new()
16351    };
16352
16353    (heading_level.is_some()
16354        || !section_path.is_empty()
16355        || section_handle.is_some()
16356        || list_depth.is_some()
16357        || fence_language.is_some()
16358        || !embedded_symbols.is_empty())
16359    .then_some(MarkdownSpanMetadata {
16360        heading_level,
16361        section_path,
16362        section_handle,
16363        list_depth,
16364        fence_language,
16365        embedded_symbols,
16366    })
16367}
16368
16369fn markdown_symbol_hit_metadata(
16370    symbol: &index::SymbolHit,
16371    source: &[u8],
16372    start_byte: usize,
16373) -> Option<MarkdownSpanMetadata> {
16374    if symbol.language != "markdown" {
16375        return None;
16376    }
16377    let heading_level = (symbol.kind == "heading")
16378        .then(|| markdown_heading_level(source, start_byte))
16379        .flatten();
16380    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16381    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16382    let embedded_symbols = if symbol.kind == "code_block" {
16383        markdown_embedded_symbols(
16384            &symbol.file,
16385            source,
16386            symbol_span_byte(symbol.body_start_byte),
16387            symbol_span_byte(symbol.body_end_byte),
16388            fence_language.as_deref(),
16389        )
16390    } else {
16391        Vec::new()
16392    };
16393    (heading_level.is_some()
16394        || list_depth.is_some()
16395        || fence_language.is_some()
16396        || !embedded_symbols.is_empty())
16397    .then_some(MarkdownSpanMetadata {
16398        heading_level,
16399        section_path: Vec::new(),
16400        section_handle: None,
16401        list_depth,
16402        fence_language,
16403        embedded_symbols,
16404    })
16405}
16406
16407fn is_markdown_path(path: &Path) -> bool {
16408    path.extension()
16409        .and_then(|ext| ext.to_str())
16410        .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
16411        .unwrap_or(false)
16412}
16413
16414fn markdown_ast_block_kind(kind: &str) -> String {
16415    match kind {
16416        "heading" => "section",
16417        "code_block" => "fenced_code_block",
16418        "list_item" => "list_item",
16419        other => other,
16420    }
16421    .to_string()
16422}
16423
16424fn markdown_embedded_language_key(language: &str) -> Option<String> {
16425    let key = language
16426        .split_whitespace()
16427        .next()
16428        .unwrap_or("")
16429        .trim()
16430        .trim_start_matches("language-")
16431        .trim_start_matches("lang-")
16432        .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
16433        .to_ascii_lowercase();
16434    (!key.is_empty()).then_some(key)
16435}
16436
16437fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
16438    let key = markdown_embedded_language_key(language)?;
16439    let extension = match key.as_str() {
16440        "rust" => "rs",
16441        "python" => "py",
16442        "typescript" => "ts",
16443        "javascript" => "js",
16444        "kotlin" => "kt",
16445        "shell" | "sh" | "zsh" => "bash",
16446        other => other,
16447    };
16448    let lang = graph::Lang::from_extension(extension)?;
16449    (lang.name() != "markdown").then_some(lang)
16450}
16451
16452fn markdown_embedded_ast_span_handle(
16453    file: &str,
16454    language: &str,
16455    name: &str,
16456    kind: &str,
16457    start_byte: usize,
16458    end_byte: usize,
16459) -> String {
16460    stable_handle(
16461        "span",
16462        &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16463    )
16464}
16465
16466fn markdown_embedded_symbols(
16467    file: &str,
16468    source: &[u8],
16469    body_start_byte: Option<usize>,
16470    body_end_byte: Option<usize>,
16471    fence_language: Option<&str>,
16472) -> Vec<MarkdownEmbeddedSymbol> {
16473    let Some(fence_language) = fence_language else {
16474        return Vec::new();
16475    };
16476    let Some(lang) = markdown_embedded_lang(fence_language) else {
16477        return Vec::new();
16478    };
16479    let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16480        return Vec::new();
16481    };
16482    let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16483    else {
16484        return Vec::new();
16485    };
16486    if body.is_empty() {
16487        return Vec::new();
16488    }
16489
16490    let Ok(symbols) = lang.extract_symbols(body) else {
16491        return Vec::new();
16492    };
16493    let language = lang.name().to_string();
16494    symbols
16495        .into_iter()
16496        .map(|symbol| {
16497            let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16498            let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16499            let body_start = symbol
16500                .body_start_byte
16501                .map(|byte| body_start_byte.saturating_add(byte));
16502            let body_end = symbol
16503                .body_end_byte
16504                .map(|byte| body_start_byte.saturating_add(byte));
16505            let start_line = source_line_for_byte(source, start_byte);
16506            let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16507            MarkdownEmbeddedSymbol {
16508                handle: markdown_embedded_ast_span_handle(
16509                    file,
16510                    &language,
16511                    &symbol.name,
16512                    &symbol.kind,
16513                    start_byte,
16514                    end_byte,
16515                ),
16516                name: symbol.name,
16517                kind: symbol.kind,
16518                language: language.clone(),
16519                node_kind: symbol.node_kind,
16520                start_byte,
16521                end_byte,
16522                start_line,
16523                end_line,
16524                body_start_byte: body_start,
16525                body_end_byte: body_end,
16526                body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16527                body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16528            }
16529        })
16530        .collect()
16531}
16532
16533fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16534    let start = start_byte.min(source.len());
16535    let line_start = source[..start]
16536        .iter()
16537        .rposition(|value| *value == b'\n')
16538        .map(|pos| pos + 1)
16539        .unwrap_or(0);
16540    let line_end = source[start..]
16541        .iter()
16542        .position(|value| *value == b'\n')
16543        .map(|pos| start + pos)
16544        .unwrap_or(source.len());
16545    std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16546}
16547
16548fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16549    let line = markdown_source_line(source, start_byte);
16550    let trimmed = line.trim_start();
16551    for marker in ["-", "*", "+"] {
16552        if trimmed
16553            .strip_prefix(marker)
16554            .and_then(|rest| rest.strip_prefix(' '))
16555            .is_some()
16556        {
16557            return (Some(marker.to_string()), None);
16558        }
16559    }
16560
16561    let digit_end = trimmed
16562        .find(|ch: char| !ch.is_ascii_digit())
16563        .unwrap_or(trimmed.len());
16564    let (digits, rest) = trimmed.split_at(digit_end);
16565    if !digits.is_empty() {
16566        for marker in [".", ")"] {
16567            if rest
16568                .strip_prefix(marker)
16569                .and_then(|value| value.strip_prefix(' '))
16570                .is_some()
16571            {
16572                return (
16573                    Some(format!("{digits}{marker}")),
16574                    digits.parse::<usize>().ok(),
16575                );
16576            }
16577        }
16578    }
16579    (None, None)
16580}
16581
16582fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16583    let line = markdown_source_line(source, start_byte);
16584    let trimmed = line.trim_start();
16585    ["```", "~~~"]
16586        .into_iter()
16587        .find(|marker| trimmed.starts_with(marker))
16588        .map(str::to_string)
16589}
16590
16591fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16592    let mut nodes = graph::Lang::Markdown
16593        .extract_symbols(source)
16594        .context("extracting Markdown AST nodes")?
16595        .into_iter()
16596        .map(|symbol| {
16597            let body_start_byte = symbol.body_start_byte;
16598            let body_end_byte = symbol.body_end_byte;
16599            let span_handle = ast_span_handle(
16600                file,
16601                &symbol.name,
16602                &symbol.kind,
16603                symbol.start_byte,
16604                symbol.end_byte,
16605            );
16606            MarkdownAstRawNode {
16607                handle: stable_handle(
16608                    "mdast",
16609                    &format!(
16610                        "{}:{}:{}:{}:{}",
16611                        file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16612                    ),
16613                ),
16614                span_handle,
16615                name: symbol.name,
16616                kind: symbol.kind.clone(),
16617                block_kind: markdown_ast_block_kind(&symbol.kind),
16618                node_kind: symbol.node_kind,
16619                start_byte: symbol.start_byte,
16620                end_byte: symbol.end_byte,
16621                body_start_byte,
16622                body_end_byte,
16623            }
16624        })
16625        .collect::<Vec<_>>();
16626    nodes.sort_by(|left, right| {
16627        left.start_byte
16628            .cmp(&right.start_byte)
16629            .then(left.end_byte.cmp(&right.end_byte))
16630            .then(left.kind.cmp(&right.kind))
16631            .then(left.name.cmp(&right.name))
16632    });
16633    Ok(nodes)
16634}
16635
16636pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16637    let source_hash = blake3::hash(source).to_hex().to_string();
16638    let cache_key = format!("{file}:{source_hash}");
16639    let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16640    if let Some(entry) = cache
16641        .lock()
16642        .expect("markdown ast cache poisoned")
16643        .get(&cache_key)
16644    {
16645        return Ok(MarkdownAstProjection {
16646            source_hash: entry.source_hash.clone(),
16647            nodes: entry.nodes.clone(),
16648            parse_duration_micros: entry.parse_duration_micros,
16649            cache_hit: true,
16650        });
16651    }
16652
16653    let started = Instant::now();
16654    let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16655    let parse_duration_micros = started.elapsed().as_micros();
16656    cache.lock().expect("markdown ast cache poisoned").insert(
16657        cache_key,
16658        MarkdownAstCacheEntry {
16659            source_hash: source_hash.clone(),
16660            nodes: nodes.clone(),
16661            parse_duration_micros,
16662        },
16663    );
16664    Ok(MarkdownAstProjection {
16665        source_hash,
16666        nodes,
16667        parse_duration_micros,
16668        cache_hit: false,
16669    })
16670}
16671
16672fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16673    MarkdownAstCacheReport {
16674        source_hash: projection.source_hash.clone(),
16675        cache_hit: projection.cache_hit,
16676        parse_duration_micros: projection.parse_duration_micros,
16677        node_count: projection.nodes.len(),
16678        section_count: projection
16679            .nodes
16680            .iter()
16681            .filter(|node| node.kind == "heading")
16682            .count(),
16683        list_item_count: projection
16684            .nodes
16685            .iter()
16686            .filter(|node| node.kind == "list_item")
16687            .count(),
16688        code_block_count: projection
16689            .nodes
16690            .iter()
16691            .filter(|node| node.kind == "code_block")
16692            .count(),
16693    }
16694}
16695
16696fn markdown_ast_node_direct_child_count(
16697    node: &MarkdownAstRawNode,
16698    nodes: &[MarkdownAstRawNode],
16699) -> usize {
16700    nodes
16701        .iter()
16702        .filter(|candidate| {
16703            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16704        })
16705        .count()
16706}
16707
16708fn markdown_ast_outline_entry(
16709    root: &Path,
16710    file: &str,
16711    source: &[u8],
16712    nodes: &[MarkdownAstRawNode],
16713    node: &MarkdownAstRawNode,
16714    max_bytes: usize,
16715) -> MarkdownAstOutlineEntry {
16716    let line = source_line_for_byte(source, node.start_byte);
16717    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16718    MarkdownAstOutlineEntry {
16719        handle: node.handle.clone(),
16720        span_handle: node.span_handle.clone(),
16721        name: truncate_for_budget(&node.name, max_bytes),
16722        kind: node.kind.clone(),
16723        block_kind: node.block_kind.clone(),
16724        line,
16725        end_line,
16726        section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16727        child_count: markdown_ast_node_direct_child_count(node, nodes),
16728        expand: markdown_ast_command(root, file, Some(&node.handle)),
16729    }
16730}
16731
16732fn markdown_ast_outline_entries(
16733    root: &Path,
16734    file: &str,
16735    source: &[u8],
16736    nodes: &[MarkdownAstRawNode],
16737    limit: usize,
16738    max_bytes: usize,
16739) -> Vec<MarkdownAstOutlineEntry> {
16740    let mut headings = nodes
16741        .iter()
16742        .filter(|node| node.kind == "heading")
16743        .collect::<Vec<_>>();
16744    let mut blocks = nodes
16745        .iter()
16746        .filter(|node| node.kind != "heading")
16747        .collect::<Vec<_>>();
16748    headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16749    blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16750    headings
16751        .into_iter()
16752        .chain(blocks)
16753        .take(limit)
16754        .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16755        .collect()
16756}
16757
16758fn markdown_ast_node_intersects_lines(
16759    source: &[u8],
16760    node: &MarkdownAstRawNode,
16761    start: usize,
16762    end: usize,
16763) -> bool {
16764    let line = source_line_for_byte(source, node.start_byte);
16765    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16766    line <= end && end_line >= start
16767}
16768
16769fn source_read_markdown_projection(
16770    root: &Path,
16771    file: &str,
16772    source: &[u8],
16773    start: usize,
16774    end: usize,
16775    budget: ResponseBudget,
16776) -> Result<SourceReadMarkdownProjection> {
16777    let projection = markdown_ast_projection(file, source)?;
16778    let visible_nodes = projection
16779        .nodes
16780        .iter()
16781        .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16782        .collect::<Vec<_>>();
16783    let mut outline_nodes = visible_nodes.clone();
16784    outline_nodes.sort_by_key(|node| {
16785        (
16786            node.kind != "heading",
16787            node.start_byte,
16788            node.end_byte,
16789            node.name.as_str(),
16790        )
16791    });
16792    let outline = outline_nodes
16793        .into_iter()
16794        .take(budget.preview_items())
16795        .map(|node| {
16796            markdown_ast_outline_entry(
16797                root,
16798                file,
16799                source,
16800                &projection.nodes,
16801                node,
16802                budget.preview_bytes(),
16803            )
16804        })
16805        .collect::<Vec<_>>();
16806    Ok(SourceReadMarkdownProjection {
16807        handle: stable_handle(
16808            "mdproj",
16809            &format!("{file}:{start}:{end}:{}", projection.source_hash),
16810        ),
16811        mode: "window_outline".to_string(),
16812        total_nodes: projection.nodes.len(),
16813        visible_nodes: visible_nodes.len(),
16814        outline,
16815        expand: markdown_ast_command(root, file, None),
16816    })
16817}
16818
16819fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16820    if parent.handle == child.handle {
16821        return false;
16822    }
16823    parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16824}
16825
16826fn markdown_ast_parent_handle(
16827    node: &MarkdownAstRawNode,
16828    nodes: &[MarkdownAstRawNode],
16829) -> Option<String> {
16830    nodes
16831        .iter()
16832        .filter(|candidate| markdown_ast_contains(candidate, node))
16833        .min_by_key(|candidate| {
16834            (
16835                candidate.end_byte.saturating_sub(candidate.start_byte),
16836                candidate.start_byte,
16837            )
16838        })
16839        .map(|candidate| candidate.handle.clone())
16840}
16841
16842fn markdown_ast_child_handles(
16843    node: &MarkdownAstRawNode,
16844    nodes: &[MarkdownAstRawNode],
16845    limit: usize,
16846) -> Vec<String> {
16847    nodes
16848        .iter()
16849        .filter(|candidate| {
16850            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16851        })
16852        .take(limit)
16853        .map(|candidate| candidate.handle.clone())
16854        .collect()
16855}
16856
16857fn markdown_ast_section_nodes<'a>(
16858    node: &MarkdownAstRawNode,
16859    nodes: &'a [MarkdownAstRawNode],
16860) -> Vec<&'a MarkdownAstRawNode> {
16861    let mut headings = nodes
16862        .iter()
16863        .filter(|candidate| candidate.kind == "heading")
16864        .filter(|candidate| {
16865            candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16866        })
16867        .collect::<Vec<_>>();
16868    headings.sort_by(|left, right| {
16869        left.start_byte
16870            .cmp(&right.start_byte)
16871            .then(left.end_byte.cmp(&right.end_byte))
16872            .then(left.name.cmp(&right.name))
16873    });
16874    headings
16875}
16876
16877fn markdown_ast_node_metadata(
16878    file: &str,
16879    node: &MarkdownAstRawNode,
16880    source: &[u8],
16881    nodes: &[MarkdownAstRawNode],
16882) -> MarkdownAstNodeMetadata {
16883    let section_nodes = markdown_ast_section_nodes(node, nodes);
16884    let section_path = section_nodes
16885        .iter()
16886        .map(|heading| heading.name.clone())
16887        .collect::<Vec<_>>();
16888    let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16889    let heading_level = (node.kind == "heading")
16890        .then(|| markdown_heading_level(source, node.start_byte))
16891        .flatten();
16892    let (list_marker, list_order) = if node.kind == "list_item" {
16893        markdown_list_attributes(source, node.start_byte)
16894    } else {
16895        (None, None)
16896    };
16897    let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16898    let embedded_symbols = if node.kind == "code_block" {
16899        markdown_embedded_symbols(
16900            file,
16901            source,
16902            node.body_start_byte,
16903            node.body_end_byte,
16904            fence_language.as_deref(),
16905        )
16906    } else {
16907        Vec::new()
16908    };
16909    MarkdownAstNodeMetadata {
16910        heading_level,
16911        section_path,
16912        section_handle,
16913        list_depth: (node.kind == "list_item")
16914            .then(|| markdown_list_depth(source, node.start_byte)),
16915        list_marker,
16916        list_order,
16917        fence_language,
16918        fence_marker: (node.kind == "code_block")
16919            .then(|| markdown_fence_marker(source, node.start_byte))
16920            .flatten(),
16921        embedded_symbols,
16922    }
16923}
16924
16925fn markdown_ast_node_expand(
16926    root: &Path,
16927    file: &str,
16928    node: &MarkdownAstRawNode,
16929    source: &[u8],
16930) -> MarkdownAstNodeExpand {
16931    let start_line = source_line_for_byte(source, node.start_byte);
16932    let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16933    let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16934    let body_start_line = node
16935        .body_start_byte
16936        .map(|byte| source_line_for_byte(source, byte))
16937        .unwrap_or(start_line);
16938    let body_end_line = node
16939        .body_end_byte
16940        .map(|byte| source_line_for_end_byte(source, byte))
16941        .unwrap_or(end_line)
16942        .max(body_start_line);
16943    let body_line_count = body_end_line
16944        .saturating_sub(body_start_line)
16945        .saturating_add(1)
16946        .max(1);
16947    MarkdownAstNodeExpand {
16948        source_window: source_read_command(root, file, start_line, line_count),
16949        source_body: source_read_command(root, file, body_start_line, body_line_count),
16950        symbol_read: source_symbol_read_command(root, &node.name, file),
16951        edit_intents: markdown_edit_intents_command(root),
16952    }
16953}
16954
16955fn markdown_ast_node(
16956    root: &Path,
16957    file: &str,
16958    node: &MarkdownAstRawNode,
16959    source: &[u8],
16960    nodes: &[MarkdownAstRawNode],
16961    child_limit: usize,
16962) -> MarkdownAstNode {
16963    let line = source_line_for_byte(source, node.start_byte);
16964    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16965    let body_byte_span = node
16966        .body_start_byte
16967        .zip(node.body_end_byte)
16968        .map(|(start, end)| SourceByteRangePreview { start, end });
16969    MarkdownAstNode {
16970        handle: node.handle.clone(),
16971        span_handle: node.span_handle.clone(),
16972        name: node.name.clone(),
16973        kind: node.kind.clone(),
16974        block_kind: node.block_kind.clone(),
16975        node_kind: node.node_kind.clone(),
16976        line,
16977        end_line,
16978        byte_span: SourceByteRangePreview {
16979            start: node.start_byte,
16980            end: node.end_byte,
16981        },
16982        body_byte_span,
16983        parent_handle: markdown_ast_parent_handle(node, nodes),
16984        child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16985        metadata: markdown_ast_node_metadata(file, node, source, nodes),
16986        expand: markdown_ast_node_expand(root, file, node, source),
16987    }
16988}
16989
16990pub(crate) fn stored_symbol_ast_span(
16991    symbol: &index::StoredSymbol,
16992    source: &[u8],
16993    symbols: &[index::StoredSymbol],
16994    child_limit: usize,
16995) -> Option<AstSpanPreview> {
16996    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16997    let node_kind = symbol.node_kind.clone()?;
16998    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16999    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17000    Some(AstSpanPreview {
17001        handle: ast_span_handle(
17002            &symbol.file,
17003            &symbol.name,
17004            &symbol.kind,
17005            start_byte,
17006            end_byte,
17007        ),
17008        node_kind,
17009        start_byte,
17010        end_byte,
17011        start_line: source_line_for_byte(source, start_byte),
17012        end_line: source_line_for_end_byte(source, end_byte),
17013        body_start_byte,
17014        body_end_byte,
17015        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17016        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17017        parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
17018        child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
17019        markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
17020    })
17021}
17022
17023pub(crate) fn symbol_hit_ast_span(
17024    symbol: &index::SymbolHit,
17025    source: &[u8],
17026) -> Option<AstSpanPreview> {
17027    let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
17028    let node_kind = symbol.node_kind.clone()?;
17029    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17030    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17031    Some(AstSpanPreview {
17032        handle: ast_span_handle(
17033            &symbol.file,
17034            &symbol.name,
17035            &symbol.kind,
17036            start_byte,
17037            end_byte,
17038        ),
17039        node_kind,
17040        start_byte,
17041        end_byte,
17042        start_line: source_line_for_byte(source, start_byte),
17043        end_line: source_line_for_end_byte(source, end_byte),
17044        body_start_byte,
17045        body_end_byte,
17046        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17047        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17048        parent_handle: None,
17049        child_handles: Vec::new(),
17050        markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
17051    })
17052}
17053
17054pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
17055    usize::try_from(symbol.line)
17056        .ok()
17057        .and_then(|line| line.checked_add(1))
17058        .unwrap_or(1)
17059}
17060
17061pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
17062    symbol
17063        .end_line
17064        .and_then(|line| usize::try_from(line).ok())
17065        .and_then(|line| line.checked_add(1))
17066}
17067
17068fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
17069    if end == 0 {
17070        return false;
17071    }
17072    let symbol_start = source_symbol_line(symbol);
17073    let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
17074    symbol_start <= end && symbol_end >= start
17075}
17076
17077#[allow(clippy::too_many_arguments)]
17078fn load_source_symbols(
17079    root: &Path,
17080    file_abs: &Path,
17081    file_display: &str,
17082    source: &[u8],
17083    scope: Option<&str>,
17084    start: usize,
17085    end: usize,
17086    limit: usize,
17087    max_bytes: usize,
17088    warnings: &mut Vec<String>,
17089) -> Vec<SourceSymbolRef> {
17090    let target = match resolve_query_index_target(root, file_abs, scope) {
17091        Ok(target) => target,
17092        Err(err) => {
17093            warnings.push(format!("index refs unavailable: {err:#}"));
17094            return Vec::new();
17095        }
17096    };
17097    // Build/refresh the per-package cargo index on demand so source-read delivers
17098    // AST symbol refs for any workspace member — not only members a prior
17099    // graph/search/explain query happened to index. `open_index_db` (the
17100    // search/explain/graph path) already ensures the index is current; before
17101    // this, source-read only checked `db_path.exists()`, so a workspace member
17102    // that had never been queried (≈60% of members here) silently degraded to
17103    // window-only output with an "index refs unavailable" warning even though
17104    // `tsift status` reported the index fresh (#cargoidxcov).
17105    if let Err(err) = ensure_query_index_current(root, &target) {
17106        warnings.push(format!("index refs unavailable: {err:#}"));
17107        return Vec::new();
17108    }
17109    let db_path = target.db_path;
17110    if !db_path.exists() {
17111        warnings.push(format!(
17112            "index refs unavailable: no index found at {}",
17113            db_path.display()
17114        ));
17115        return Vec::new();
17116    }
17117
17118    let db = match index::IndexDb::open_read_only_resilient(&db_path) {
17119        Ok(db) => db,
17120        Err(err) => {
17121            warnings.push(format!("index refs unavailable: {err:#}"));
17122            return Vec::new();
17123        }
17124    };
17125
17126    let file_key = file_abs.to_string_lossy().to_string();
17127    let symbols = match db.symbols_for_file(&file_key) {
17128        Ok(symbols) => symbols,
17129        Err(err) => {
17130            warnings.push(format!("symbol refs unavailable: {err:#}"));
17131            return Vec::new();
17132        }
17133    };
17134
17135    symbols
17136        .iter()
17137        .filter(|symbol| source_symbol_intersects(symbol, start, end))
17138        .take(limit)
17139        .map(|symbol| {
17140            let line = source_symbol_line(symbol);
17141            let end_line = source_symbol_end_line(symbol);
17142            let handle = stable_handle(
17143                "ssym",
17144                &format!("{}:{}:{}", file_display, symbol.name, line),
17145            );
17146            SourceSymbolRef {
17147                handle,
17148                name: truncate_for_budget(&symbol.name, max_bytes),
17149                kind: symbol.kind.clone(),
17150                language: symbol.language.clone(),
17151                file: file_display.to_string(),
17152                line,
17153                end_line,
17154                signature: symbol
17155                    .signature
17156                    .clone()
17157                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
17158                span: stored_symbol_ast_span(symbol, source, &symbols, limit),
17159                expand: source_symbol_read_command(root, &symbol.name, file_display),
17160            }
17161        })
17162        .collect()
17163}
17164
17165fn load_source_summaries(
17166    root: &Path,
17167    file_display: &str,
17168    limit: usize,
17169    max_bytes: usize,
17170    warnings: &mut Vec<String>,
17171) -> Vec<SourceSummaryRef> {
17172    let db_path = root.join(".tsift/summaries.db");
17173    if !db_path.exists() {
17174        return Vec::new();
17175    }
17176    let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
17177        Ok(db) => db,
17178        Err(err) => {
17179            warnings.push(format!("summary refs unavailable: {err:#}"));
17180            return Vec::new();
17181        }
17182    };
17183    let summaries = match db.get_by_file(file_display) {
17184        Ok(summaries) => summaries,
17185        Err(err) => {
17186            warnings.push(format!("summary refs unavailable: {err:#}"));
17187            return Vec::new();
17188        }
17189    };
17190
17191    summaries
17192        .into_iter()
17193        .take(limit)
17194        .map(|summary| SourceSummaryRef {
17195            handle: stable_handle(
17196                "sum",
17197                &format!(
17198                    "{}:{}:{}",
17199                    summary.file_path, summary.symbol_name, summary.id
17200                ),
17201            ),
17202            symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
17203            file_path: summary.file_path,
17204            summary: truncate_for_budget(&summary.summary, max_bytes),
17205            expand: source_summary_expand_command(root, &summary.symbol_name),
17206        })
17207        .collect()
17208}
17209
17210fn cmd_markdown_ast(
17211    file: &Path,
17212    path: &Path,
17213    node: Option<&str>,
17214    format: OutputFormat,
17215    absolute: bool,
17216    budget: ResponseBudget,
17217) -> Result<()> {
17218    let root = lint::resolve_project_root_or_canonical_path(path)?;
17219    let file_abs = resolve_source_file(&root, file)?;
17220    if !is_markdown_path(&file_abs) {
17221        bail!(
17222            "markdown-ast only supports Markdown files (.md/.mdx): {}",
17223            file_abs.display()
17224        );
17225    }
17226    let file_display = if absolute {
17227        file_abs.to_string_lossy().to_string()
17228    } else {
17229        relativize_pathbuf(&file_abs, &root)
17230            .to_string_lossy()
17231            .to_string()
17232    };
17233    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17234    let text = String::from_utf8_lossy(&source);
17235    let total_lines = text.lines().count();
17236    let projection = markdown_ast_projection(&file_display, &source)?;
17237    let raw_nodes = &projection.nodes;
17238    let max_items = budget.preview_items();
17239    let max_bytes = budget.preview_bytes();
17240
17241    let selected_nodes = if let Some(handle) = node {
17242        let matches = raw_nodes
17243            .iter()
17244            .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
17245            .collect::<Vec<_>>();
17246        if matches.is_empty() {
17247            bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
17248        }
17249        matches
17250    } else {
17251        raw_nodes.iter().take(max_items).collect::<Vec<_>>()
17252    };
17253    let nodes = selected_nodes
17254        .into_iter()
17255        .map(|raw| {
17256            let mut node =
17257                markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
17258            node.name = truncate_for_budget(&node.name, max_bytes);
17259            node
17260        })
17261        .collect::<Vec<_>>();
17262    let outline_started = Instant::now();
17263    let outline = markdown_ast_outline_entries(
17264        &root,
17265        &file_display,
17266        &source,
17267        raw_nodes,
17268        max_items,
17269        max_bytes,
17270    );
17271    let outline_duration_micros = outline_started.elapsed().as_micros();
17272    let projection_preview = MarkdownAstProjectionPreview {
17273        mode: if node.is_some() {
17274            "selected_node".to_string()
17275        } else {
17276            "outline_first".to_string()
17277        },
17278        total_nodes: raw_nodes.len(),
17279        returned_nodes: nodes.len(),
17280        omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
17281        selected_node: node.map(str::to_string),
17282        cache: markdown_ast_cache_report(&projection),
17283        outline,
17284        phase_timings: vec![
17285            MarkdownAstPhaseTiming {
17286                name: "parse_extract".to_string(),
17287                duration_micros: projection.parse_duration_micros,
17288                detail: if projection.cache_hit {
17289                    "reused cached tree-sitter Markdown symbol extraction".to_string()
17290                } else {
17291                    "tree-sitter Markdown symbol extraction".to_string()
17292                },
17293            },
17294            MarkdownAstPhaseTiming {
17295                name: "outline_projection".to_string(),
17296                duration_micros: outline_duration_micros,
17297                detail: "outline-first section/block preview construction".to_string(),
17298            },
17299        ],
17300    };
17301    let report = MarkdownAstReport {
17302        handle: stable_handle("mdastrep", &file_display),
17303        root: root.to_string_lossy().to_string(),
17304        file: file_display.clone(),
17305        range: SourceRangePreview {
17306            start: 1,
17307            end: total_lines,
17308            total_lines,
17309            truncated_before: false,
17310            truncated_after: false,
17311        },
17312        projection: projection_preview,
17313        nodes,
17314        expand: MarkdownAstExpandCommands {
17315            file: markdown_ast_command(&root, &file_display, None),
17316            source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
17317            edit_intents: markdown_edit_intents_command(&root),
17318        },
17319        warnings: Vec::new(),
17320    };
17321
17322    if format.json_output {
17323        let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
17324        let mut follow_up = vec![
17325            report.expand.file.clone(),
17326            report.expand.source_read.clone(),
17327            report.expand.edit_intents.clone(),
17328        ];
17329        follow_up.extend(
17330            report
17331                .nodes
17332                .iter()
17333                .map(|node| node.expand.source_window.clone()),
17334        );
17335        print_json_or_envelope(
17336            &report,
17337            &format,
17338            "markdown-ast",
17339            "ast",
17340            ToolEnvelopeSummary {
17341                text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
17342                metrics: vec![
17343                    envelope_metric("nodes", report.nodes.len()),
17344                    envelope_metric("total_nodes", report.projection.total_nodes),
17345                    envelope_metric(
17346                        "parse_duration_micros",
17347                        report.projection.cache.parse_duration_micros,
17348                    ),
17349                    envelope_metric("total_lines", report.range.total_lines),
17350                ],
17351            },
17352            truncated,
17353            follow_up,
17354        )?;
17355    } else if format.compact {
17356        println!(
17357            "markdown-ast {} nodes:{} handle:{}",
17358            report.file,
17359            report.nodes.len(),
17360            report.handle
17361        );
17362        for node in &report.nodes {
17363            println!(
17364                "  {} {} {}:{}-{}",
17365                node.handle, node.kind, node.name, node.line, node.end_line
17366            );
17367        }
17368        if node.is_none() && raw_nodes.len() > report.nodes.len() {
17369            println!("expand: {}", report.expand.file);
17370        }
17371    } else {
17372        println!(
17373            "Markdown AST `{}` nodes {} of {} ({})",
17374            report.file,
17375            report.nodes.len(),
17376            raw_nodes.len(),
17377            report.handle
17378        );
17379        for node in &report.nodes {
17380            println!(
17381                "  {} `{}` {}:{}-{} — {}",
17382                node.handle,
17383                node.name,
17384                node.kind,
17385                node.line,
17386                node.end_line,
17387                node.expand.source_window
17388            );
17389        }
17390        if node.is_none() && raw_nodes.len() > report.nodes.len() {
17391            println!();
17392            println!("Expand:");
17393            println!("  file: {}", report.expand.file);
17394        }
17395    }
17396
17397    Ok(())
17398}
17399
17400#[allow(clippy::too_many_arguments)]
17401fn cmd_source_read(
17402    file: &Path,
17403    path: &Path,
17404    style: SourceReadStyle,
17405    start: usize,
17406    lines: usize,
17407    end: Option<usize>,
17408    scope: Option<&str>,
17409    format: OutputFormat,
17410    absolute: bool,
17411    budget: ResponseBudget,
17412) -> Result<()> {
17413    if start == 0 {
17414        bail!("--start is 1-based and must be greater than zero");
17415    }
17416    if lines == 0 {
17417        bail!("--lines must be greater than zero");
17418    }
17419    if let Some(end) = end
17420        && end < start
17421    {
17422        bail!("--end must be greater than or equal to --start");
17423    }
17424
17425    let root = lint::resolve_project_root_or_canonical_path(path)?;
17426    let file_abs = resolve_source_file(&root, file)?;
17427    let file_display = if absolute {
17428        file_abs.to_string_lossy().to_string()
17429    } else {
17430        relativize_pathbuf(&file_abs, &root)
17431            .to_string_lossy()
17432            .to_string()
17433    };
17434
17435    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17436    let text = String::from_utf8_lossy(&source);
17437    let all_lines: Vec<&str> = text.lines().collect();
17438    let total_lines = all_lines.len();
17439    if total_lines > 0 && start > total_lines {
17440        bail!(
17441            "--start {} is beyond end of {} ({} lines)",
17442            start,
17443            file_display,
17444            total_lines
17445        );
17446    }
17447    let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17448    let end_line = requested_end.min(total_lines);
17449    let mut warnings = Vec::new();
17450    let max_items = budget.preview_items();
17451    let max_bytes = budget.preview_bytes();
17452    if style == SourceReadStyle::Ast {
17453        let symbols = load_source_symbols(
17454            &root,
17455            &file_abs,
17456            &file_display,
17457            &source,
17458            scope,
17459            start,
17460            end_line,
17461            max_items,
17462            max_bytes,
17463            &mut warnings,
17464        );
17465        let summaries =
17466            load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17467        let markdown = if is_markdown_path(&file_abs) {
17468            match source_read_markdown_projection(
17469                &root,
17470                &file_display,
17471                &source,
17472                start,
17473                end_line,
17474                budget,
17475            ) {
17476                Ok(markdown) => Some(markdown),
17477                Err(err) => {
17478                    warnings.push(format!("markdown projection unavailable: {err:#}"));
17479                    None
17480                }
17481            }
17482        } else {
17483            None
17484        };
17485        let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17486        let report = SourceReadAstReport {
17487            handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17488            root: root.to_string_lossy().to_string(),
17489            file: file_display.clone(),
17490            range: SourceRangePreview {
17491                start,
17492                end: end_line,
17493                total_lines,
17494                truncated_before: start > 1,
17495                truncated_after: end_line < total_lines,
17496            },
17497            symbols,
17498            summaries,
17499            markdown,
17500            expand: SourceReadAstExpandCommands {
17501                window: source_read_window_command(&root, &file_display, start, window_lines),
17502                file_window: source_read_window_command(
17503                    &root,
17504                    &file_display,
17505                    1,
17506                    total_lines.max(window_lines),
17507                ),
17508                markdown_ast: is_markdown_path(&file_abs)
17509                    .then(|| markdown_ast_command(&root, &file_display, None)),
17510            },
17511            warnings,
17512        };
17513
17514        if format.json_output {
17515            let truncated = report.range.truncated_before
17516                || report.range.truncated_after
17517                || report.symbols.len() >= max_items
17518                || report.summaries.len() >= max_items;
17519            let follow_up = [
17520                Some(report.expand.window.clone()),
17521                Some(report.expand.file_window.clone()),
17522                report.expand.markdown_ast.clone(),
17523            ]
17524            .into_iter()
17525            .flatten()
17526            .collect::<Vec<_>>();
17527            print_json_or_envelope(
17528                &report,
17529                &format,
17530                "source-read",
17531                "ast",
17532                ToolEnvelopeSummary {
17533                    text: format!(
17534                        "source ast {}:{}-{}",
17535                        report.file, report.range.start, report.range.end
17536                    ),
17537                    metrics: vec![
17538                        envelope_metric("symbols", report.symbols.len()),
17539                        envelope_metric("summaries", report.summaries.len()),
17540                        envelope_metric(
17541                            "markdown_nodes",
17542                            report
17543                                .markdown
17544                                .as_ref()
17545                                .map_or(0, |markdown| markdown.visible_nodes),
17546                        ),
17547                    ],
17548                },
17549                truncated,
17550                follow_up,
17551            )?;
17552        } else if format.compact {
17553            println!(
17554                "source-ast {}:{}-{} / {} handle:{}",
17555                report.file,
17556                report.range.start,
17557                report.range.end,
17558                report.range.total_lines,
17559                report.handle
17560            );
17561            for symbol in &report.symbols {
17562                println!(
17563                    "  {} {}:{} {}",
17564                    symbol.name, symbol.file, symbol.line, symbol.expand
17565                );
17566            }
17567            if !report.summaries.is_empty() {
17568                println!("summaries[{}]", report.summaries.len());
17569            }
17570            for warning in &report.warnings {
17571                eprintln!("warning: {warning}");
17572            }
17573        } else {
17574            println!(
17575                "Source AST `{}` lines {}-{} of {} ({})",
17576                report.file,
17577                report.range.start,
17578                report.range.end,
17579                report.range.total_lines,
17580                report.handle
17581            );
17582            if !report.symbols.is_empty() {
17583                println!();
17584                println!("Symbol refs:");
17585                for symbol in &report.symbols {
17586                    println!(
17587                        "  {} `{}` {}:{} — {}",
17588                        symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17589                    );
17590                }
17591            }
17592            if !report.summaries.is_empty() {
17593                println!();
17594                println!("Summary refs:");
17595                for summary in &report.summaries {
17596                    println!(
17597                        "  {} `{}` — {}",
17598                        summary.handle, summary.symbol_name, summary.expand
17599                    );
17600                }
17601            }
17602            println!();
17603            println!("Expand:");
17604            println!("  window:      {}", report.expand.window);
17605            println!("  file window: {}", report.expand.file_window);
17606            if let Some(markdown_ast) = &report.expand.markdown_ast {
17607                println!("  markdown:    {}", markdown_ast);
17608            }
17609            for warning in &report.warnings {
17610                eprintln!("warning: {warning}");
17611            }
17612        }
17613
17614        return Ok(());
17615    }
17616    let max_bytes = budget.preview_bytes();
17617    let token_cap = budget.body_token_cap();
17618    let (preview, preview_end, body_truncated) = if total_lines == 0 {
17619        (Vec::new(), end_line, false)
17620    } else {
17621        let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17622        (capped.preview, capped.capped_end, capped.was_capped)
17623    };
17624    let effective_end = if body_truncated {
17625        preview_end
17626    } else {
17627        end_line
17628    };
17629
17630    if body_truncated {
17631        warnings.push(format!(
17632            "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17633        ));
17634    }
17635    let symbols = load_source_symbols(
17636        &root,
17637        &file_abs,
17638        &file_display,
17639        &source,
17640        scope,
17641        start,
17642        effective_end,
17643        max_items,
17644        max_bytes,
17645        &mut warnings,
17646    );
17647    let summaries =
17648        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17649    let markdown = if is_markdown_path(&file_abs) {
17650        match source_read_markdown_projection(
17651            &root,
17652            &file_display,
17653            &source,
17654            start,
17655            effective_end,
17656            budget,
17657        ) {
17658            Ok(markdown) => Some(markdown),
17659            Err(err) => {
17660                warnings.push(format!("markdown projection unavailable: {err:#}"));
17661                None
17662            }
17663        }
17664    } else {
17665        None
17666    };
17667
17668    let expand = SourceExpandCommands {
17669        before: (start > 1).then(|| {
17670            let before_start = start.saturating_sub(lines).max(1);
17671            source_read_window_command(&root, &file_display, before_start, start - before_start)
17672        }),
17673        after: (effective_end < total_lines)
17674            .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17675        body: body_truncated.then(|| {
17676            let remaining = end_line.saturating_sub(effective_end);
17677            source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17678        }),
17679        file: source_read_ast_command(&root, &file_display),
17680        markdown_ast: is_markdown_path(&file_abs)
17681            .then(|| markdown_ast_command(&root, &file_display, None)),
17682    };
17683
17684    let report = SourceReadReport {
17685        handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17686        root: root.to_string_lossy().to_string(),
17687        file: file_display,
17688        range: SourceRangePreview {
17689            start,
17690            end: effective_end,
17691            total_lines,
17692            truncated_before: start > 1,
17693            truncated_after: effective_end < total_lines,
17694        },
17695        preview,
17696        symbols,
17697        summaries,
17698        markdown,
17699        expand,
17700        warnings,
17701    };
17702
17703    if format.json_output {
17704        let truncated = report.range.truncated_before || report.range.truncated_after;
17705        let follow_up = [
17706            report.expand.before.clone(),
17707            report.expand.after.clone(),
17708            report.expand.body.clone(),
17709            Some(report.expand.file.clone()),
17710            report.expand.markdown_ast.clone(),
17711        ]
17712        .into_iter()
17713        .flatten()
17714        .collect::<Vec<_>>();
17715        print_json_or_envelope(
17716            &report,
17717            &format,
17718            "source-read",
17719            "window",
17720            ToolEnvelopeSummary {
17721                text: format!(
17722                    "source window {}:{}-{}",
17723                    report.file, report.range.start, report.range.end
17724                ),
17725                metrics: vec![
17726                    envelope_metric("lines", report.preview.len()),
17727                    envelope_metric("symbols", report.symbols.len()),
17728                    envelope_metric("summaries", report.summaries.len()),
17729                    envelope_metric(
17730                        "markdown_nodes",
17731                        report
17732                            .markdown
17733                            .as_ref()
17734                            .map_or(0, |markdown| markdown.visible_nodes),
17735                    ),
17736                ],
17737            },
17738            truncated,
17739            follow_up,
17740        )?;
17741    } else if format.compact {
17742        println!(
17743            "source {}:{}-{} / {} handle:{}",
17744            report.file,
17745            report.range.start,
17746            report.range.end,
17747            report.range.total_lines,
17748            report.handle
17749        );
17750        for line in &report.preview {
17751            println!("{:>5} {}", line.line, line.text);
17752        }
17753        if !report.symbols.is_empty() {
17754            println!("syms[{}]:", report.symbols.len());
17755            for symbol in &report.symbols {
17756                println!("  {} {}:{}", symbol.name, symbol.file, symbol.line);
17757            }
17758        }
17759        if report.range.truncated_before || report.range.truncated_after {
17760            println!("expand: {}", report.expand.file);
17761        }
17762    } else {
17763        println!(
17764            "Source window `{}` lines {}-{} of {} ({})",
17765            report.file,
17766            report.range.start,
17767            report.range.end,
17768            report.range.total_lines,
17769            report.handle
17770        );
17771        for line in &report.preview {
17772            println!("{:>5} | {}", line.line, line.text);
17773        }
17774        if !report.symbols.is_empty() {
17775            println!();
17776            println!("Symbol refs:");
17777            for symbol in &report.symbols {
17778                println!(
17779                    "  {} `{}` {}:{} — {}",
17780                    symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17781                );
17782            }
17783        }
17784        if !report.summaries.is_empty() {
17785            println!();
17786            println!("Summary refs:");
17787            for summary in &report.summaries {
17788                println!(
17789                    "  {} `{}` — {}",
17790                    summary.handle, summary.symbol_name, summary.expand
17791                );
17792            }
17793        }
17794        if report.range.truncated_before || report.range.truncated_after {
17795            println!();
17796            println!("Expand:");
17797            if let Some(before) = &report.expand.before {
17798                println!("  before: {}", before);
17799            }
17800            if let Some(after) = &report.expand.after {
17801                println!("  after: {}", after);
17802            }
17803            println!("  file:   {}", report.expand.file);
17804        }
17805        for warning in &report.warnings {
17806            eprintln!("warning: {warning}");
17807        }
17808    }
17809
17810    Ok(())
17811}
17812
17813#[allow(clippy::too_many_arguments)]
17814fn cmd_symbol_read(
17815    symbol: &str,
17816    file_hint: Option<&Path>,
17817    path: &Path,
17818    scope: Option<&str>,
17819    format: OutputFormat,
17820    absolute: bool,
17821    budget: ResponseBudget,
17822) -> Result<()> {
17823    let root = lint::resolve_project_root_or_canonical_path(path)?;
17824    let hinted_file_abs = file_hint
17825        .map(|file| resolve_source_file(&root, file))
17826        .transpose()?;
17827    let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17828    // Build/refresh the per-package cargo index on demand so symbol-read resolves
17829    // symbols in any workspace member, not only ones a prior graph/search query
17830    // indexed. Previously this checked existence only and bailed with "no index
17831    // found" for never-queried members despite a fresh `tsift status`
17832    // (#cargoidxcov).
17833    let target = resolve_query_index_target(&root, path_hint, scope)?;
17834    ensure_query_index_current(&root, &target)?;
17835    let db_path = target.db_path;
17836    if !db_path.exists() {
17837        bail!(
17838            "index refs unavailable: no index found at {}",
17839            db_path.display()
17840        );
17841    }
17842    let db = index::IndexDb::open_read_only_resilient(&db_path)
17843        .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17844    let search_limit = budget.follow_up_items().max(10);
17845    let hits = db
17846        .symbol_search(symbol, search_limit)
17847        .with_context(|| format!("searching symbols for {symbol:?}"))?;
17848    let selected = hits
17849        .into_iter()
17850        .find(|hit| {
17851            let Some(hinted_file_abs) = &hinted_file_abs else {
17852                return true;
17853            };
17854            resolve_source_file(&root, Path::new(&hit.file))
17855                .map(|hit_file| hit_file == *hinted_file_abs)
17856                .unwrap_or(false)
17857        })
17858        .with_context(|| {
17859            let hint = file_hint
17860                .map(|file| format!(" in {}", file.display()))
17861                .unwrap_or_default();
17862            format!("no indexed symbol matched {symbol:?}{hint}")
17863        })?;
17864
17865    let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17866    let file_display = if absolute {
17867        file_abs.to_string_lossy().to_string()
17868    } else {
17869        relativize_pathbuf(&file_abs, &root)
17870            .to_string_lossy()
17871            .to_string()
17872    };
17873    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17874    let content_hash = blake3::hash(&source).to_hex().to_string();
17875    let text = String::from_utf8_lossy(&source);
17876    let all_lines: Vec<&str> = text.lines().collect();
17877    let total_lines = all_lines.len();
17878    let file_symbols = db
17879        .symbols_for_file(&file_abs.to_string_lossy())
17880        .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17881    let max_items = budget.preview_items();
17882    let max_bytes = budget.preview_bytes();
17883    let selected_start = symbol_hit_line(&selected);
17884    let selected_end = symbol_hit_end_line(&selected)
17885        .unwrap_or(selected_start)
17886        .max(selected_start);
17887    let stored_target = file_symbols.iter().find(|candidate| {
17888        candidate.name == selected.name
17889            && candidate.kind == selected.kind
17890            && source_symbol_line(candidate) == selected_start
17891    });
17892    let target_span = stored_target
17893        .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17894        .or_else(|| symbol_hit_ast_span(&selected, &source));
17895    let target_start = target_span
17896        .as_ref()
17897        .map(|span| span.start_line)
17898        .unwrap_or(selected_start);
17899    let target_end = target_span
17900        .as_ref()
17901        .map(|span| span.end_line)
17902        .or_else(|| stored_target.and_then(source_symbol_end_line))
17903        .unwrap_or(selected_end)
17904        .max(target_start);
17905    let target_bounds = stored_target
17906        .and_then(stored_symbol_span_bounds)
17907        .or_else(|| symbol_hit_span_bounds(&selected));
17908    let target_end = stored_target
17909        .and_then(source_symbol_end_line)
17910        .unwrap_or(target_end)
17911        .max(target_start);
17912    let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17913    let line_capped_end = target_start
17914        .saturating_add(body_line_budget)
17915        .saturating_sub(1)
17916        .min(target_end)
17917        .min(total_lines.max(target_start));
17918    let token_cap = budget.body_token_cap();
17919    let (body, effective_preview_end, body_truncated) =
17920        if total_lines == 0 || target_start > total_lines {
17921            (Vec::new(), line_capped_end, false)
17922        } else {
17923            let capped = build_token_capped_preview(
17924                &all_lines,
17925                target_start,
17926                line_capped_end,
17927                max_bytes,
17928                token_cap,
17929            );
17930            (capped.preview, capped.capped_end, capped.was_capped)
17931        };
17932    let preview_end = if body_truncated {
17933        effective_preview_end
17934    } else {
17935        line_capped_end
17936    };
17937    let child_symbols = file_symbols
17938        .iter()
17939        .filter(|candidate| {
17940            if let Some((target_start_byte, target_end_byte)) = target_bounds {
17941                let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17942                else {
17943                    return false;
17944                };
17945                return candidate_start >= target_start_byte
17946                    && candidate_end <= target_end_byte
17947                    && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17948            }
17949            let line = source_symbol_line(candidate);
17950            line > target_start && line <= target_end
17951        })
17952        .take(max_items)
17953        .map(|symbol| {
17954            let line = source_symbol_line(symbol);
17955            let end_line = source_symbol_end_line(symbol);
17956            SourceSymbolRef {
17957                handle: stable_handle(
17958                    "ssym",
17959                    &format!("{}:{}:{}", file_display, symbol.name, line),
17960                ),
17961                name: truncate_for_budget(&symbol.name, max_bytes),
17962                kind: symbol.kind.clone(),
17963                language: symbol.language.clone(),
17964                file: file_display.clone(),
17965                line,
17966                end_line,
17967                signature: symbol
17968                    .signature
17969                    .clone()
17970                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
17971                span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17972                expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17973            }
17974        })
17975        .collect::<Vec<_>>();
17976    let mut warnings = Vec::new();
17977    if body_truncated {
17978        warnings.push(format!(
17979            "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17980        ));
17981    }
17982    let summaries =
17983        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17984    let symbol_handle = stable_handle(
17985        "sread",
17986        &format!("{}:{}:{}", file_display, selected.name, target_start),
17987    );
17988    let source_lines = preview_end
17989        .saturating_sub(target_start)
17990        .saturating_add(1)
17991        .max(1);
17992    let expand = SymbolReadExpandCommands {
17993        source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17994        body: body_truncated.then(|| {
17995            let remaining = target_end.saturating_sub(preview_end);
17996            source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17997        }),
17998        file: source_read_ast_command(&root, &file_display),
17999        explain: source_symbol_expand_command(&root, &selected.name),
18000        callers: source_symbol_graph_command(&root, &selected.name, "callers"),
18001        callees: source_symbol_graph_command(&root, &selected.name, "callees"),
18002        markdown_ast: (selected.language == "markdown").then(|| {
18003            markdown_ast_command(
18004                &root,
18005                &file_display,
18006                target_span.as_ref().map(|span| span.handle.as_str()),
18007            )
18008        }),
18009    };
18010    let report = SymbolReadReport {
18011        handle: symbol_handle.clone(),
18012        root: root.to_string_lossy().to_string(),
18013        query: symbol.to_string(),
18014        symbol: SymbolReadTarget {
18015            handle: symbol_handle,
18016            name: selected.name.clone(),
18017            kind: selected.kind.clone(),
18018            language: selected.language.clone(),
18019            file: file_display.clone(),
18020            line: target_start,
18021            end_line: Some(target_end),
18022            signature: stored_target
18023                .and_then(|stored| stored.signature.clone())
18024                .map(|signature| truncate_for_budget(&signature, max_bytes)),
18025            parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
18026            visibility: stored_target.and_then(|stored| stored.visibility.clone()),
18027            span: target_span,
18028        },
18029        range: SourceRangePreview {
18030            start: target_start,
18031            end: preview_end,
18032            total_lines,
18033            truncated_before: false,
18034            truncated_after: preview_end < target_end,
18035        },
18036        body,
18037        child_symbols,
18038        summaries,
18039        expand,
18040        warnings,
18041    };
18042
18043    if format.json_output {
18044        let truncated = report.range.truncated_after
18045            || report.body.iter().any(|line| line.text.len() >= max_bytes)
18046            || report.child_symbols.len() >= max_items;
18047        let follow_up = [
18048            Some(report.expand.source_window.clone()),
18049            report.expand.body.clone(),
18050            Some(report.expand.file.clone()),
18051            Some(report.expand.explain.clone()),
18052            Some(report.expand.callers.clone()),
18053            Some(report.expand.callees.clone()),
18054        ]
18055        .into_iter()
18056        .flatten()
18057        .chain(report.expand.markdown_ast.clone())
18058        .collect::<Vec<_>>();
18059        print_json_or_envelope(
18060            &report,
18061            &format,
18062            "symbol-read",
18063            "symbol",
18064            ToolEnvelopeSummary {
18065                text: format!(
18066                    "symbol {} {}:{}-{}",
18067                    report.symbol.name, report.symbol.file, report.range.start, report.range.end
18068                ),
18069                metrics: vec![
18070                    envelope_metric("body_lines", report.body.len()),
18071                    envelope_metric("child_symbols", report.child_symbols.len()),
18072                    envelope_metric("summaries", report.summaries.len()),
18073                ],
18074            },
18075            truncated,
18076            follow_up,
18077        )?;
18078    } else if format.compact {
18079        println!(
18080            "symbol {} {}:{}-{} handle:{} hash:{}",
18081            report.symbol.name,
18082            report.symbol.file,
18083            report.range.start,
18084            report.range.end,
18085            report.handle,
18086            content_hash
18087        );
18088        for line in &report.body {
18089            println!("{:>5} {}", line.line, line.text);
18090        }
18091        if !report.child_symbols.is_empty() {
18092            println!("children[{}]:", report.child_symbols.len());
18093            for child in &report.child_symbols {
18094                println!("  {} {}:{}", child.name, child.file, child.line);
18095            }
18096        }
18097    } else {
18098        println!(
18099            "Symbol `{}` in `{}` lines {}-{} ({})",
18100            report.symbol.name,
18101            report.symbol.file,
18102            report.range.start,
18103            report.range.end,
18104            report.handle
18105        );
18106        for line in &report.body {
18107            println!("{:>5} | {}", line.line, line.text);
18108        }
18109        if !report.child_symbols.is_empty() {
18110            println!();
18111            println!("Child symbols:");
18112            for child in &report.child_symbols {
18113                println!(
18114                    "  {} `{}` {}:{} — {}",
18115                    child.handle, child.name, child.file, child.line, child.expand
18116                );
18117            }
18118        }
18119        println!();
18120        println!("Expand:");
18121        println!("  source:  {}", report.expand.source_window);
18122        println!("  file:    {}", report.expand.file);
18123        println!("  explain: {}", report.expand.explain);
18124        println!("  callers: {}", report.expand.callers);
18125        println!("  callees: {}", report.expand.callees);
18126        for warning in &report.warnings {
18127            eprintln!("warning: {warning}");
18128        }
18129    }
18130
18131    Ok(())
18132}
18133
18134#[allow(clippy::too_many_arguments)]
18135#[derive(Serialize)]
18136struct ExplainBudgetDefinitionPreview {
18137    handle: String,
18138    #[serde(skip_serializing_if = "Option::is_none")]
18139    tag_alias: Option<String>,
18140    kind: String,
18141    name: String,
18142    file: String,
18143    line: i64,
18144    expand: String,
18145}
18146
18147#[derive(Serialize)]
18148struct ExplainBudgetEdgePreview {
18149    handle: String,
18150    #[serde(skip_serializing_if = "Option::is_none")]
18151    tag_alias: Option<String>,
18152    name: String,
18153    file: String,
18154    line: i64,
18155    expand: String,
18156}
18157
18158#[derive(Serialize)]
18159struct ExplainBudgetCommunityPreview {
18160    size: usize,
18161    members: Vec<String>,
18162}
18163
18164#[derive(Serialize)]
18165struct ExplainBudgetReport {
18166    symbol: String,
18167    max_items: usize,
18168    max_bytes: usize,
18169    definition_total: usize,
18170    callers_total: usize,
18171    callers_truncated_by_limit: bool,
18172    callees_total: usize,
18173    callees_truncated_by_limit: bool,
18174    truncated: bool,
18175    definitions: Vec<ExplainBudgetDefinitionPreview>,
18176    callers: Vec<ExplainBudgetEdgePreview>,
18177    callees: Vec<ExplainBudgetEdgePreview>,
18178    #[serde(skip_serializing_if = "Option::is_none")]
18179    community: Option<ExplainBudgetCommunityPreview>,
18180}
18181
18182#[allow(clippy::too_many_arguments)]
18183pub(crate) fn build_explain_budget_report(
18184    symbol: &str,
18185    _root: &Path,
18186    symbols: &[index::StoredSymbol],
18187    callers: &[index::StoredEdge],
18188    callers_total: usize,
18189    callers_truncated_by_limit: bool,
18190    callees: &[index::StoredEdge],
18191    callees_total: usize,
18192    callees_truncated_by_limit: bool,
18193    community: Option<&graph::Community>,
18194    budget: ResponseBudget,
18195) -> ExplainBudgetReport {
18196    let max_items = budget.preview_items();
18197    let max_bytes = budget.preview_bytes();
18198    let definitions = symbols
18199        .iter()
18200        .take(max_items)
18201        .map(|entry| {
18202            let symbol_ref = build_compact_symbol_ref(
18203                "edef",
18204                &format!(
18205                    "{}:{}:{}:{}",
18206                    entry.kind, entry.name, entry.file, entry.line
18207                ),
18208                &entry.name,
18209                entry.tags.as_deref(),
18210                max_bytes,
18211            );
18212            ExplainBudgetDefinitionPreview {
18213                handle: symbol_ref.handle,
18214                tag_alias: symbol_ref.tag_alias,
18215                kind: entry.kind.clone(),
18216                name: symbol_ref.name,
18217                file: truncate_for_budget(&entry.file, max_bytes),
18218                line: entry.line,
18219                expand: format!(
18220                    "tsift search {} --exact --path {} --limit 20",
18221                    shell_quote(&entry.name),
18222                    shell_quote(&entry.file)
18223                ),
18224            }
18225        })
18226        .collect();
18227    let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
18228        .iter()
18229        .take(max_items)
18230        .map(|entry| {
18231            let symbol_ref = build_compact_symbol_ref(
18232                "ecall",
18233                &format!(
18234                    "{}:{}:{}:{}",
18235                    entry.caller_name, entry.caller_file, entry.call_site_line, symbol
18236                ),
18237                &entry.caller_name,
18238                None,
18239                max_bytes,
18240            );
18241            ExplainBudgetEdgePreview {
18242                handle: symbol_ref.handle,
18243                tag_alias: symbol_ref.tag_alias,
18244                name: symbol_ref.name,
18245                file: truncate_for_budget(&entry.caller_file, max_bytes),
18246                line: entry.call_site_line,
18247                expand: format!(
18248                    "tsift explain {} --path {} --limit 0",
18249                    shell_quote(&entry.caller_name),
18250                    shell_quote(&entry.caller_file)
18251                ),
18252            }
18253        })
18254        .collect();
18255    let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
18256        .iter()
18257        .take(max_items)
18258        .map(|entry| {
18259            let symbol_ref = build_compact_symbol_ref(
18260                "eces",
18261                &format!(
18262                    "{}:{}:{}:{}",
18263                    entry.callee_name, entry.caller_file, entry.call_site_line, symbol
18264                ),
18265                &entry.callee_name,
18266                None,
18267                max_bytes,
18268            );
18269            ExplainBudgetEdgePreview {
18270                handle: symbol_ref.handle,
18271                tag_alias: symbol_ref.tag_alias,
18272                name: symbol_ref.name,
18273                file: truncate_for_budget(&entry.caller_file, max_bytes),
18274                line: entry.call_site_line,
18275                expand: format!(
18276                    "tsift explain {} --path {} --limit 0",
18277                    shell_quote(&entry.callee_name),
18278                    shell_quote(&entry.caller_file)
18279                ),
18280            }
18281        })
18282        .collect();
18283    let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
18284        size: entry.members.len(),
18285        members: entry
18286            .members
18287            .iter()
18288            .take(max_items)
18289            .map(|member| truncate_for_budget(&member.name, max_bytes))
18290            .collect(),
18291    });
18292
18293    ExplainBudgetReport {
18294        symbol: symbol.to_string(),
18295        max_items,
18296        max_bytes,
18297        definition_total: symbols.len(),
18298        callers_total,
18299        callers_truncated_by_limit,
18300        callees_total,
18301        callees_truncated_by_limit,
18302        truncated: symbols.len() > max_items
18303            || callers_total > callers_preview.len()
18304            || callees_total > callees_preview.len()
18305            || community
18306                .map(|entry| entry.members.len() > max_items)
18307                .unwrap_or(false),
18308        definitions,
18309        callers: callers_preview,
18310        callees: callees_preview,
18311        community: community_preview,
18312    }
18313}
18314
18315pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
18316    println!(
18317        "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
18318        shell_quote(&report.symbol),
18319        report.definitions.len(),
18320        report.definition_total,
18321        report.callers.len(),
18322        report.callers_total,
18323        report.callees.len(),
18324        report.callees_total
18325    );
18326    for entry in &report.definitions {
18327        println!(
18328            "def {} {} {}:{} expand:{}",
18329            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18330            entry.kind,
18331            entry.file,
18332            entry.line,
18333            entry.expand
18334        );
18335    }
18336    for entry in &report.callers {
18337        println!(
18338            "caller {} {}:{} expand:{}",
18339            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18340            entry.file,
18341            entry.line,
18342            entry.expand
18343        );
18344    }
18345    for entry in &report.callees {
18346        println!(
18347            "callee {} {}:{} expand:{}",
18348            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18349            entry.file,
18350            entry.line,
18351            entry.expand
18352        );
18353    }
18354    if let Some(community) = &report.community {
18355        println!(
18356            "community size:{} members:{}",
18357            community.size,
18358            community.members.join(", ")
18359        );
18360    }
18361    if report.truncated {
18362        println!(
18363            "budget truncated items:{} bytes:{}",
18364            report.max_items, report.max_bytes
18365        );
18366    }
18367}
18368
18369/// Reconcile the tsift symbol index against the tagpath `.naming/index.json`
18370/// source set and report files covered by one but not the other.
18371///
18372/// Today silent recall loss happens when tagpath's `[exclude]` / `extends`
18373/// chain or its hard-coded `SKIP_DIRS` skip files or languages that tsift
18374/// still indexes — the tsift symbols in those files cannot resolve a
18375/// `tagpath_handle` even with a fresh tagpath index. This audit surfaces
18376/// the diff so operators can decide whether to broaden the tagpath walk,
18377/// add an `[exclude]` to tsift, or accept the gap.
18378const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
18379    ".git",
18380    "node_modules",
18381    "target",
18382    "__pycache__",
18383    ".venv",
18384    "vendor",
18385];
18386
18387const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
18388    "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
18389    "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
18390    "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
18391    "sh", "bash", "zsh", "sql", "css", "tsx",
18392];
18393
18394pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
18395    let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
18396        .iter()
18397        .map(|ext| (*ext).to_string())
18398        .collect::<BTreeSet<_>>();
18399
18400    let config_path = root.join(".naming.toml");
18401    if !config_path.exists() {
18402        return extensions;
18403    }
18404
18405    match tagpath::config::resolve(&config_path) {
18406        Ok(config) => {
18407            if let Some(grammars) = config.grammars {
18408                for grammar in grammars.languages.values() {
18409                    for ext in &grammar.extensions {
18410                        if let Some(normalized) = normalize_extension(ext) {
18411                            extensions.insert(normalized);
18412                        }
18413                    }
18414                }
18415            }
18416        }
18417        Err(err) => {
18418            eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18419        }
18420    }
18421    extensions
18422}
18423
18424pub(crate) fn tagpath_audit_policy_hints(
18425    rel_path: &str,
18426    supported_extensions: &BTreeSet<String>,
18427) -> Vec<String> {
18428    let path = Path::new(rel_path);
18429    let mut hints = BTreeSet::new();
18430    if let Some(parent) = path.parent() {
18431        for component in parent.components() {
18432            if let std::path::Component::Normal(name) = component {
18433                let name = name.to_string_lossy();
18434                if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18435                    hints.insert(format!("skip_dir:{name}"));
18436                }
18437            }
18438        }
18439    }
18440    if path
18441        .extension()
18442        .and_then(|ext| ext.to_str())
18443        .and_then(normalize_extension)
18444        .is_some_and(|ext| !supported_extensions.contains(&ext))
18445    {
18446        hints.insert("extension_unsupported".to_string());
18447    }
18448    hints.into_iter().collect()
18449}
18450
18451fn normalize_extension(ext: &str) -> Option<String> {
18452    let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18453    if normalized.is_empty() {
18454        None
18455    } else {
18456        Some(normalized)
18457    }
18458}
18459
18460pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18461    match status {
18462        diff_digest::DiffDigestFileStatus::Added => "added",
18463        diff_digest::DiffDigestFileStatus::Modified => "modified",
18464        diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18465    }
18466}
18467
18468pub(crate) fn diff_digest_summary_label(
18469    state: diff_digest::DiffDigestSummaryState,
18470) -> &'static str {
18471    match state {
18472        diff_digest::DiffDigestSummaryState::Current => "current",
18473        diff_digest::DiffDigestSummaryState::Stale => "stale",
18474        diff_digest::DiffDigestSummaryState::Missing => "missing",
18475        diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18476    }
18477}
18478
18479fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18480    match state {
18481        test_digest::TestDigestSummaryState::Current => "current",
18482        test_digest::TestDigestSummaryState::Stale => "stale",
18483        test_digest::TestDigestSummaryState::Missing => "missing",
18484        test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18485    }
18486}
18487
18488fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18489    match state {
18490        log_digest::LogDigestSummaryState::Current => "current",
18491        log_digest::LogDigestSummaryState::Stale => "stale",
18492        log_digest::LogDigestSummaryState::Missing => "missing",
18493        log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18494    }
18495}
18496
18497pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18498    match mode {
18499        diff_digest::DiffDigestMode::WorkingTree => "worktree",
18500        diff_digest::DiffDigestMode::Cached => "cached",
18501        diff_digest::DiffDigestMode::Revision => "revision",
18502    }
18503}
18504
18505pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18506    match (&report.mode, &report.revision) {
18507        (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18508        (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18509        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18510            format!("revision {revision}")
18511        }
18512        (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18513    }
18514}
18515
18516pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18517    match (&report.mode, &report.revision) {
18518        (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18519        (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18520        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18521            format!("No diff found for revision {revision}.")
18522        }
18523        (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18524    }
18525}
18526
18527fn cmd_impact(
18528    path: &Path,
18529    cached: bool,
18530    revision: Option<&str>,
18531    scope: Option<&str>,
18532    limit: usize,
18533    format: OutputFormat,
18534) -> Result<()> {
18535    let report = impact::compute(
18536        path,
18537        impact::ImpactOptions {
18538            cached,
18539            revision,
18540            scope,
18541            limit,
18542        },
18543    )?;
18544    if format.json_output {
18545        println!(
18546            "{}",
18547            to_json_schema(
18548                &report,
18549                format.pretty,
18550                format.terse,
18551                format.ultra_terse,
18552                format.schema
18553            )?
18554        );
18555        return Ok(());
18556    }
18557
18558    if format.compact {
18559        println!(
18560            "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18561            diff_digest_mode_label(report.mode),
18562            report.changed_files.len(),
18563            report.changed_symbols.len(),
18564            report.affected_tests.len(),
18565            report.affected_tests_total
18566        );
18567        for target in &report.affected_tests {
18568            println!(
18569                "{} reasons:{} command:{}",
18570                target.path,
18571                target.reasons.len(),
18572                target.commands.join(" && ")
18573            );
18574        }
18575        for warning in &report.warnings {
18576            println!("warning {warning}");
18577        }
18578        return Ok(());
18579    }
18580
18581    println!("Impact ({})", diff_digest_mode_label(report.mode));
18582    println!("  changed files:          {}", report.changed_files.len());
18583    println!("  changed symbols:        {}", report.changed_symbols.len());
18584    println!(
18585        "  affected tests:         {}/{}",
18586        report.affected_tests.len(),
18587        report.affected_tests_total
18588    );
18589    for target in &report.affected_tests {
18590        println!();
18591        println!("{}", target.path);
18592        for reason in &target.reasons {
18593            println!("  - {reason}");
18594        }
18595        if !target.symbols.is_empty() {
18596            println!("  symbols: {}", target.symbols.join(", "));
18597        }
18598        for command in &target.commands {
18599            println!("  run: {}", command);
18600        }
18601    }
18602    for warning in &report.warnings {
18603        println!("warning: {warning}");
18604    }
18605    Ok(())
18606}
18607
18608pub(crate) fn render_test_digest_from_input(
18609    path: &Path,
18610    input: &str,
18611    runner: Option<&str>,
18612    format: OutputFormat,
18613) -> Result<()> {
18614    let report = test_digest::compute(path, input, runner)?;
18615    if format.json_output {
18616        println!(
18617            "{}",
18618            to_json_schema(
18619                &report,
18620                format.pretty,
18621                format.terse,
18622                format.ultra_terse,
18623                format.schema
18624            )?
18625        );
18626        return Ok(());
18627    }
18628
18629    if report.failure_groups.is_empty() {
18630        println!("No failures detected (runner: {}).", report.runner);
18631        for warning in &report.warnings {
18632            println!("warning: {warning}");
18633        }
18634        return Ok(());
18635    }
18636
18637    if format.compact {
18638        println!(
18639            "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18640            report.runner,
18641            report.failures,
18642            report.grouped_failures,
18643            report.counts.passed.unwrap_or(0),
18644            report.counts.failed.unwrap_or(report.grouped_failures),
18645            report.counts.skipped.unwrap_or(0),
18646        );
18647        for failure in &report.failure_groups {
18648            let tests = truncate_for_compact(&failure.tests.join(","), 60);
18649            let location = match (&failure.path, failure.line) {
18650                (Some(path), Some(line)) => format!("{path}:{line}"),
18651                (Some(path), None) => path.clone(),
18652                _ => "-".to_string(),
18653            };
18654            println!(
18655                "{} tests:{} count:{} summaries:{} msg:{}",
18656                location,
18657                tests,
18658                failure.occurrences,
18659                test_digest_summary_label(failure.summary_state),
18660                truncate_for_compact(&failure.message, 80)
18661            );
18662        }
18663        for warning in &report.warnings {
18664            println!("warning: {warning}");
18665        }
18666        return Ok(());
18667    }
18668
18669    println!("Test digest ({})", report.runner);
18670    println!("  failures:        {}", report.failures);
18671    println!("  failure groups:  {}", report.grouped_failures);
18672    if let Some(passed) = report.counts.passed {
18673        println!("  passed:          {}", passed);
18674    }
18675    if let Some(failed) = report.counts.failed {
18676        println!("  failed:          {}", failed);
18677    }
18678    if let Some(skipped) = report.counts.skipped {
18679        println!("  skipped:         {}", skipped);
18680    }
18681
18682    for failure in &report.failure_groups {
18683        println!();
18684        match (&failure.path, failure.line, failure.column) {
18685            (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18686            (Some(path), Some(line), None) => println!("{path}:{line}"),
18687            (Some(path), None, _) => println!("{path}"),
18688            (None, _, _) => println!("(no file anchor)"),
18689        }
18690        println!("  tests: {}", failure.tests.join(", "));
18691        println!("  occurrences: {}", failure.occurrences);
18692        println!("  message: {}", failure.message);
18693        println!(
18694            "  cached summaries: {}",
18695            test_digest_summary_label(failure.summary_state)
18696        );
18697        for summary in &failure.current_summaries {
18698            println!(
18699                "    - {}: {}",
18700                summary.symbol,
18701                truncate_for_compact(&summary.summary, 160)
18702            );
18703        }
18704    }
18705    for warning in &report.warnings {
18706        println!("warning: {warning}");
18707    }
18708    Ok(())
18709}
18710
18711#[derive(Clone, Serialize, Deserialize)]
18712struct DispatchTraceSummary {
18713    backlog: usize,
18714    job_packet: usize,
18715    worker_result: usize,
18716    worker_context: usize,
18717    source_handle: usize,
18718    semantic_rows: usize,
18719}
18720
18721#[derive(Clone, Serialize, Deserialize)]
18722struct DispatchTraceReport {
18723    contract_version: String,
18724    root: String,
18725    #[serde(skip_serializing_if = "Option::is_none")]
18726    scope: Option<String>,
18727    targets: Vec<String>,
18728    projection_freshness: GraphDbFreshnessReport,
18729    projection_hashes: Vec<String>,
18730    evidence_packet_ids: Vec<String>,
18731    shared_preparation: ConflictMatrixSharedPreparationSummary,
18732    worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18733    worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18734    summary: DispatchTraceSummary,
18735    nodes: Vec<SubstrateTerseGraphNode>,
18736    edges: Vec<SubstrateTerseGraphEdge>,
18737    conflict_matrix_decisions: Vec<String>,
18738    replay_commands: Vec<String>,
18739    repair_commands: Vec<String>,
18740    truncated: bool,
18741    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18742    warnings: Vec<String>,
18743}
18744
18745fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18746    matches!(
18747        kind,
18748        "session"
18749            | "backlog"
18750            | "job_packet"
18751            | "worker_result"
18752            | "worker_context"
18753            | "source_handle"
18754            | "semantic_concept"
18755            | "semantic_entity"
18756            | "file"
18757            | "symbol"
18758            | "route"
18759    )
18760}
18761
18762fn dispatch_trace_kind_rank(kind: &str) -> usize {
18763    match kind {
18764        "backlog" => 0,
18765        "job_packet" => 1,
18766        "worker_result" => 2,
18767        "worker_context" => 3,
18768        "source_handle" => 4,
18769        "file" => 5,
18770        "symbol" => 6,
18771        "route" => 7,
18772        "semantic_concept" => 8,
18773        "semantic_entity" => 9,
18774        "session" => 10,
18775        _ => 99,
18776    }
18777}
18778
18779fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18780    DispatchTraceSummary {
18781        backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18782        job_packet: nodes
18783            .iter()
18784            .filter(|node| node.kind == "job_packet")
18785            .count(),
18786        worker_result: nodes
18787            .iter()
18788            .filter(|node| node.kind == "worker_result")
18789            .count(),
18790        worker_context: nodes
18791            .iter()
18792            .filter(|node| node.kind == "worker_context")
18793            .count(),
18794        source_handle: nodes
18795            .iter()
18796            .filter(|node| node.kind == "source_handle")
18797            .count(),
18798        semantic_rows: nodes
18799            .iter()
18800            .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18801            .count(),
18802    }
18803}
18804
18805fn dispatch_trace_shared_preparation_summary(
18806    graph_nodes: &[SubstrateGraphNode],
18807    graph_edges: &[SubstrateGraphEdge],
18808    conflict: &ConflictMatrixReport,
18809) -> ConflictMatrixSharedPreparationSummary {
18810    ConflictMatrixSharedPreparationSummary {
18811        evidence_cache_status: conflict
18812            .inputs
18813            .shared_preparation
18814            .evidence_cache_status
18815            .clone(),
18816        graph_nodes: graph_nodes.len(),
18817        graph_edges: graph_edges.len(),
18818        evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18819        source_handles: conflict
18820            .candidates
18821            .iter()
18822            .map(|candidate| candidate.source_handles.len())
18823            .sum(),
18824        worker_context: conflict
18825            .candidates
18826            .iter()
18827            .map(|candidate| candidate.worker_context_handles.len())
18828            .sum(),
18829        worker_results: conflict
18830            .candidates
18831            .iter()
18832            .map(|candidate| candidate.worker_feedback.total)
18833            .sum(),
18834        semantic_rows: conflict
18835            .candidates
18836            .iter()
18837            .map(|candidate| candidate.semantic_related.len())
18838            .sum(),
18839        dispatch_trace_snapshot_nodes: graph_nodes.len(),
18840        dispatch_trace_snapshot_edges: graph_edges.len(),
18841    }
18842}
18843
18844fn dispatch_trace_collect_ids(
18845    targets: &[String],
18846    candidates: &[ConflictMatrixCandidate],
18847    graph_nodes: &[SubstrateGraphNode],
18848    graph_edges: &[SubstrateGraphEdge],
18849    depth: usize,
18850    limit: usize,
18851) -> (BTreeSet<String>, bool) {
18852    let target_refs = targets
18853        .iter()
18854        .map(|target| target.trim_start_matches('#').to_string())
18855        .collect::<BTreeSet<_>>();
18856    let mut ids = BTreeSet::new();
18857    for candidate in candidates {
18858        ids.insert(candidate.target_node_id.clone());
18859        for source in &candidate.source_handles {
18860            ids.insert(source.handle.clone());
18861        }
18862        for handle in &candidate.worker_context_handles {
18863            ids.insert(handle.clone());
18864        }
18865        for semantic in &candidate.semantic_related {
18866            ids.insert(semantic.handle.clone());
18867        }
18868    }
18869    for node in graph_nodes {
18870        if !dispatch_trace_allowed_node_kind(&node.kind) {
18871            continue;
18872        }
18873        if node
18874            .properties
18875            .get("ref_id")
18876            .is_some_and(|ref_id| target_refs.contains(ref_id))
18877        {
18878            ids.insert(node.id.clone());
18879        }
18880    }
18881
18882    let node_by_id = graph_nodes
18883        .iter()
18884        .map(|node| (node.id.as_str(), node))
18885        .collect::<BTreeMap<_, _>>();
18886    let max_nodes = if limit == 0 {
18887        usize::MAX
18888    } else {
18889        limit
18890            .saturating_mul(targets.len().max(1))
18891            .saturating_mul(12)
18892            .max(64)
18893    };
18894    let mut truncated = false;
18895    for _ in 0..depth.max(1) {
18896        let before = ids.len();
18897        let current_ids = ids.clone();
18898        for edge in graph_edges {
18899            if ids.len() >= max_nodes {
18900                truncated = true;
18901                break;
18902            }
18903            let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18904            if !touches {
18905                continue;
18906            }
18907            for endpoint in [&edge.from_id, &edge.to_id] {
18908                let Some(node) = node_by_id.get(endpoint.as_str()) else {
18909                    continue;
18910                };
18911                if dispatch_trace_allowed_node_kind(&node.kind) {
18912                    ids.insert(endpoint.clone());
18913                }
18914            }
18915        }
18916        if ids.len() == before || truncated {
18917            break;
18918        }
18919    }
18920    (ids, truncated)
18921}
18922
18923#[allow(clippy::too_many_arguments)]
18924fn build_dispatch_trace_report_from_conflict_snapshot(
18925    root: &Path,
18926    scope: Option<&str>,
18927    conflict: ConflictMatrixReport,
18928    graph_nodes: Vec<SubstrateGraphNode>,
18929    graph_edges: Vec<SubstrateGraphEdge>,
18930    depth: usize,
18931    limit: usize,
18932    extra_warnings: Vec<String>,
18933) -> Result<DispatchTraceReport> {
18934    let shared_preparation =
18935        dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18936    let (ids, truncated) = dispatch_trace_collect_ids(
18937        &conflict.targets,
18938        &conflict.candidates,
18939        &graph_nodes,
18940        &graph_edges,
18941        depth,
18942        limit,
18943    );
18944    let mut nodes = graph_nodes
18945        .into_iter()
18946        .filter(|node| ids.contains(&node.id))
18947        .collect::<Vec<_>>();
18948    nodes.sort_by(|left, right| {
18949        dispatch_trace_kind_rank(&left.kind)
18950            .cmp(&dispatch_trace_kind_rank(&right.kind))
18951            .then(left.id.cmp(&right.id))
18952    });
18953    let node_ids = nodes
18954        .iter()
18955        .map(|node| node.id.as_str())
18956        .collect::<BTreeSet<_>>();
18957    let mut edges = graph_edges
18958        .into_iter()
18959        .filter(|edge| {
18960            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18961        })
18962        .collect::<Vec<_>>();
18963    edges.sort_by(|left, right| {
18964        left.from_id
18965            .cmp(&right.from_id)
18966            .then(left.kind.cmp(&right.kind))
18967            .then(left.to_id.cmp(&right.to_id))
18968    });
18969    let mut warnings = conflict.warnings;
18970    warnings.extend(extra_warnings);
18971
18972    Ok(DispatchTraceReport {
18973        contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18974        root: conflict.root,
18975        scope: conflict.scope,
18976        targets: conflict.targets,
18977        projection_freshness: conflict.orchestration.projection_freshness,
18978        projection_hashes: conflict.orchestration.projection_hashes,
18979        evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18980        shared_preparation,
18981        worker_prompt_packets: conflict.worker_prompt_packets,
18982        worker_feedback: conflict
18983            .candidates
18984            .iter()
18985            .map(|candidate| candidate.worker_feedback.clone())
18986            .collect(),
18987        summary: dispatch_trace_summary(&nodes),
18988        nodes: nodes.into_iter().map(Into::into).collect(),
18989        edges: edges.into_iter().map(Into::into).collect(),
18990        conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18991        replay_commands: conflict.next_commands,
18992        repair_commands: graph_db_repair_commands(root, scope),
18993        truncated,
18994        warnings,
18995    })
18996}
18997
18998fn build_dispatch_trace_report(
18999    path: &Path,
19000    scope: Option<&str>,
19001    raw_targets: &[String],
19002    depth: usize,
19003    limit: usize,
19004    impact_limit: usize,
19005) -> Result<DispatchTraceReport> {
19006    let root = lint::resolve_project_root_or_canonical_path(path)?;
19007    let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
19008    if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
19009        write_traversal_graph_store(&root, path, scope)
19010            .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19011    }
19012    let graph_db = graph_substrate_db_path(&root, scope);
19013    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19014        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19015    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19016    let extra_warnings = store
19017        .read_only_recovery()
19018        .map(graph_db_read_recovery_diagnostic)
19019        .into_iter()
19020        .collect::<Vec<_>>();
19021    let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
19022    let graph_prepared = prepare_conflict_matrix_graph_orchestration(
19023        &root,
19024        scope,
19025        "sqlite",
19026        raw_targets,
19027        &prepared,
19028        depth,
19029        limit,
19030        &store,
19031        freshness.clone(),
19032    )?;
19033    let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
19034        &prepared.preparation_cache.source_watermark,
19035        &prepared.preparation_cache.document_watermark,
19036        &prepared.preparation_cache.staged_diff_watermark,
19037        &[
19038            &format!("targets:{}", raw_targets.join(",")),
19039            &format!("depth:{depth}"),
19040            &format!("limit:{limit}"),
19041        ],
19042    );
19043    if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
19044        &root,
19045        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19046        &dt_cache_key,
19047    ) {
19048        return Ok(cached_report);
19049    }
19050    let conflict = build_conflict_matrix_report_from_prepared_graph(
19051        &root,
19052        path,
19053        scope,
19054        depth,
19055        limit,
19056        impact_limit,
19057        freshness,
19058        extra_warnings.clone(),
19059        &prepared,
19060        &graph_prepared,
19061    )?;
19062    let report = build_dispatch_trace_report_from_conflict_snapshot(
19063        &root,
19064        scope,
19065        conflict,
19066        graph_prepared.graph.nodes,
19067        graph_prepared.graph.edges,
19068        depth,
19069        limit,
19070        extra_warnings,
19071    )?;
19072    cycle_packet_cache::cycle_packet_write_cache(
19073        &root,
19074        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19075        &dt_cache_key,
19076        &report,
19077    );
19078    Ok(report)
19079}
19080
19081fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
19082    let json = serde_json::to_string(report)?.replace("</", "<\\/");
19083    let mut html = String::new();
19084    html.push_str(
19085        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
19086    );
19087    html.push_str(
19088        r#"<style>
19089:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
19090@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
19091*{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}}
19092</style>"#,
19093    );
19094    html.push_str("</head><body><div class=\"page\">");
19095    html.push_str(&format!(
19096        "<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>",
19097        html_escape(&report.targets.join(", ")),
19098        report.evidence_packet_ids.len(),
19099        report.nodes.len(),
19100        report.worker_prompt_packets.len(),
19101        html_escape(&report.contract_version)
19102    ));
19103    html.push_str(
19104        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>"#,
19105    );
19106    html.push_str("<script id=\"trace-data\" type=\"application/json\">");
19107    html.push_str(&json);
19108    html.push_str(
19109        r##"</script><script>
19110const report = JSON.parse(document.getElementById("trace-data").textContent);
19111const svg = document.getElementById("graph-canvas");
19112const nodeList = document.getElementById("nodes");
19113const packets = document.getElementById("packets");
19114const feedback = document.getElementById("feedback");
19115const nodes = report.nodes.map((node, index) => ({...node, index}));
19116const nodeById = new Map(nodes.map(node => [node.id, node]));
19117const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
19118const 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"]]);
19119function color(kind){return colorByKind.get(kind)||"#6b7280";}
19120function text(value){return value == null ? "" : String(value);}
19121function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));}
19122function layout(){
19123  const rect = svg.getBoundingClientRect();
19124  const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
19125  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
19126  const counts = new Map();
19127  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
19128  const offsets = new Map();
19129  for (const node of nodes) {
19130    const group = kinds.indexOf(node.kind);
19131    const index = offsets.get(node.kind) || 0;
19132    offsets.set(node.kind, index + 1);
19133    const total = counts.get(node.kind) || 1;
19134    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
19135    const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
19136    node.x = cx + Math.cos(angle) * ring;
19137    node.y = cy + Math.sin(angle) * ring;
19138  }
19139}
19140function draw(){
19141  svg.innerHTML = "";
19142  for (const edge of edges) {
19143    const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
19144    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
19145    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
19146    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
19147    line.setAttribute("class", "edge");
19148    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
19149    svg.appendChild(line);
19150  }
19151  for (const node of nodes) {
19152    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
19153    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
19154    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
19155    circle.setAttribute("fill", color(node.kind));
19156    circle.setAttribute("class", "node");
19157    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
19158    svg.appendChild(circle);
19159    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
19160    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
19161    label.setAttribute("class", "node-label");
19162    label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
19163    svg.appendChild(label);
19164  }
19165}
19166packets.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>";
19167feedback.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>";
19168nodeList.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("");
19169window.addEventListener("resize", () => { layout(); draw(); });
19170layout(); draw();
19171</script></div></body></html>"##,
19172    );
19173    Ok(html)
19174}
19175
19176struct DispatchTraceOptions<'a> {
19177    path: &'a Path,
19178    scope: Option<&'a str>,
19179    raw_targets: &'a [String],
19180    depth: usize,
19181    limit: usize,
19182    impact_limit: usize,
19183    trace_format: DispatchTraceFormat,
19184}
19185
19186fn cmd_dispatch_trace(
19187    options: DispatchTraceOptions<'_>,
19188    output_format: OutputFormat,
19189) -> Result<()> {
19190    let report = build_dispatch_trace_report(
19191        options.path,
19192        options.scope,
19193        options.raw_targets,
19194        options.depth,
19195        options.limit,
19196        options.impact_limit,
19197    )?;
19198    match options.trace_format {
19199        DispatchTraceFormat::Json => {
19200            if output_format.envelope {
19201                print_json_or_envelope(
19202                    &report,
19203                    &output_format,
19204                    "dispatch-trace",
19205                    "operator-review",
19206                    ToolEnvelopeSummary {
19207                        text: format!(
19208                            "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
19209                            report.targets.len(),
19210                            report.nodes.len(),
19211                            report.worker_prompt_packets.len()
19212                        ),
19213                        metrics: vec![
19214                            envelope_metric("targets", report.targets.len()),
19215                            envelope_metric("nodes", report.nodes.len()),
19216                            envelope_metric("edges", report.edges.len()),
19217                            envelope_metric(
19218                                "worker_prompt_packets",
19219                                report.worker_prompt_packets.len(),
19220                            ),
19221                        ],
19222                    },
19223                    report.truncated,
19224                    report.replay_commands.clone(),
19225                )
19226            } else {
19227                println!(
19228                    "{}",
19229                    to_json_schema(
19230                        &report,
19231                        output_format.pretty,
19232                        output_format.terse,
19233                        output_format.ultra_terse,
19234                        output_format.schema
19235                    )?
19236                );
19237                Ok(())
19238            }
19239        }
19240        DispatchTraceFormat::Html => {
19241            println!("{}", dispatch_trace_html(&report)?);
19242            Ok(())
19243        }
19244    }
19245}
19246
19247#[derive(Clone, Debug)]
19248struct DependencyDagProfile {
19249    id: String,
19250    graph_node_id: String,
19251    label: String,
19252    path: Option<String>,
19253    line: Option<i64>,
19254    detail: Option<String>,
19255    source_files: BTreeSet<String>,
19256    source_symbols: BTreeSet<String>,
19257    config_files: BTreeSet<String>,
19258    expected_tests: BTreeSet<String>,
19259    semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
19260    worker_feedback: ConflictMatrixWorkerFeedback,
19261}
19262
19263#[derive(Clone, Debug, Serialize)]
19264struct DependencyDagNode {
19265    id: String,
19266    graph_node_id: String,
19267    label: String,
19268    #[serde(skip_serializing_if = "Option::is_none")]
19269    path: Option<String>,
19270    #[serde(skip_serializing_if = "Option::is_none")]
19271    line: Option<i64>,
19272    #[serde(skip_serializing_if = "Option::is_none")]
19273    detail: Option<String>,
19274    source_files: Vec<String>,
19275    source_symbols: Vec<String>,
19276    config_files: Vec<String>,
19277    expected_tests: Vec<String>,
19278    semantic_refs: Vec<ConflictMatrixSemanticRef>,
19279    worker_feedback: ConflictMatrixWorkerFeedback,
19280}
19281
19282#[derive(Clone, Debug, Serialize)]
19283struct DependencyDagEdge {
19284    from: String,
19285    to: String,
19286    kind: String,
19287    weight: usize,
19288    reasons: Vec<String>,
19289    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19290    shared_files: Vec<String>,
19291    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19292    shared_symbols: Vec<String>,
19293    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19294    shared_tests: Vec<String>,
19295    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19296    shared_config_files: Vec<String>,
19297    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19298    shared_semantic_refs: Vec<String>,
19299}
19300
19301#[derive(Clone, Debug, Serialize)]
19302struct DependencyDagTopoBatch {
19303    batch: usize,
19304    targets: Vec<String>,
19305}
19306
19307#[derive(Clone, Debug, Serialize)]
19308struct DependencyDagCycleDiagnostics {
19309    has_cycles: bool,
19310    blocked_nodes: Vec<String>,
19311    cycle_edges: Vec<DependencyDagEdge>,
19312}
19313
19314#[derive(Serialize)]
19315struct DependencyDagSummary {
19316    nodes: usize,
19317    edges: usize,
19318    topo_batches: usize,
19319    has_cycles: bool,
19320}
19321
19322#[derive(Serialize)]
19323struct DependencyDagReport {
19324    contract_version: &'static str,
19325    root: String,
19326    #[serde(skip_serializing_if = "Option::is_none")]
19327    scope: Option<String>,
19328    path: String,
19329    targets: Vec<String>,
19330    projection_freshness: GraphDbFreshnessReport,
19331    projection_hashes: Vec<String>,
19332    nodes: Vec<DependencyDagNode>,
19333    edges: Vec<DependencyDagEdge>,
19334    topo_batches: Vec<DependencyDagTopoBatch>,
19335    cycle_diagnostics: DependencyDagCycleDiagnostics,
19336    summary: DependencyDagSummary,
19337    replay_commands: Vec<String>,
19338    repair_commands: Vec<String>,
19339    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19340    warnings: Vec<String>,
19341}
19342
19343fn dependency_dag_backlog_node_for_target(
19344    store: &impl GraphStore,
19345    target: &str,
19346) -> Result<SubstrateGraphNode> {
19347    let resolved = graph_db_resolve_evidence_target(store, target)?
19348        .with_context(|| format!("dependency-dag target not found: {target}"))?;
19349    if resolved.kind == "backlog" {
19350        return Ok(resolved);
19351    }
19352    let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
19353        bail!(
19354            "dependency-dag target {} resolved to {} without a backlog ref_id",
19355            target,
19356            resolved.kind
19357        );
19358    };
19359    store
19360        .nodes_by_kind("backlog")?
19361        .into_iter()
19362        .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
19363        .min_by(|left, right| {
19364            left.properties
19365                .get("line")
19366                .and_then(|value| value.parse::<i64>().ok())
19367                .cmp(
19368                    &right
19369                        .properties
19370                        .get("line")
19371                        .and_then(|value| value.parse::<i64>().ok()),
19372                )
19373                .then(left.id.cmp(&right.id))
19374        })
19375        .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
19376}
19377
19378fn dependency_dag_resolve_backlog_nodes(
19379    root: &Path,
19380    path: &Path,
19381    store: &impl GraphStore,
19382    raw_targets: &[String],
19383) -> Result<Vec<SubstrateGraphNode>> {
19384    let mut nodes = Vec::new();
19385    let mut seen = BTreeSet::new();
19386    if raw_targets.is_empty() {
19387        let hinted_path = if path.is_absolute() {
19388            path.to_path_buf()
19389        } else {
19390            root.join(path)
19391        };
19392        let hinted_markdown = hinted_path
19393            .extension()
19394            .and_then(|ext| ext.to_str())
19395            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
19396        let hinted_rel = hinted_markdown.then(|| {
19397            relativize_pathbuf(&hinted_path, root)
19398                .to_string_lossy()
19399                .replace('\\', "/")
19400        });
19401        for node in store.nodes_by_kind("backlog")? {
19402            if let Some(expected_path) = &hinted_rel
19403                && node.properties.get("path") != Some(expected_path)
19404            {
19405                continue;
19406            }
19407            if seen.insert(node.id.clone()) {
19408                nodes.push(node);
19409            }
19410        }
19411        if nodes.is_empty() && hinted_rel.is_some() {
19412            for node in store.nodes_by_kind("backlog")? {
19413                if seen.insert(node.id.clone()) {
19414                    nodes.push(node);
19415                }
19416            }
19417        }
19418    } else {
19419        for target in raw_targets {
19420            let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19421            let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19422            if seen.insert(node.id.clone()) {
19423                nodes.push(node);
19424            }
19425        }
19426    }
19427    if nodes.is_empty() {
19428        bail!("dependency-dag needs at least one resolvable backlog id");
19429    }
19430    nodes.sort_by(|left, right| {
19431        left.properties
19432            .get("line")
19433            .and_then(|value| value.parse::<i64>().ok())
19434            .cmp(
19435                &right
19436                    .properties
19437                    .get("line")
19438                    .and_then(|value| value.parse::<i64>().ok()),
19439            )
19440            .then(left.id.cmp(&right.id))
19441    });
19442    Ok(nodes)
19443}
19444
19445fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19446    node.properties
19447        .get("ref_id")
19448        .cloned()
19449        .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19450}
19451
19452fn dependency_dag_node_profile(
19453    root: &Path,
19454    store: &impl GraphStore,
19455    node: &SubstrateGraphNode,
19456    graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19457    graph_edges: &[SubstrateGraphEdge],
19458    depth: usize,
19459    limit: usize,
19460) -> Result<DependencyDagProfile> {
19461    let id = dependency_dag_node_id(node);
19462    let mut source_files = BTreeSet::new();
19463    let mut source_symbols = BTreeSet::new();
19464    for edge in graph_edges
19465        .iter()
19466        .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19467    {
19468        let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19469            continue;
19470        };
19471        match target.kind.as_str() {
19472            "file" | "route" => {
19473                if let Some(path) = target.properties.get("path") {
19474                    source_files.insert(path.clone());
19475                }
19476            }
19477            "symbol" => {
19478                source_symbols.insert(target.label.clone());
19479                if let Some(path) = target.properties.get("path") {
19480                    source_files.insert(path.clone());
19481                }
19482            }
19483            _ => {}
19484        }
19485    }
19486
19487    let max_rows = if limit == 0 { usize::MAX } else { limit };
19488    for (source, _) in
19489        graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19490    {
19491        let terse: SubstrateTerseGraphNode = (&source).into();
19492        if let Some(handle) = conflict_matrix_source_handle(&terse) {
19493            source_files.insert(handle.file);
19494        }
19495    }
19496
19497    let worker_results = graph_nodes_by_id
19498        .values()
19499        .filter(|candidate| {
19500            candidate.kind == "worker_result"
19501                && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19502        })
19503        .map(SubstrateTerseGraphNode::from)
19504        .collect::<Vec<_>>();
19505    let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19506    let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19507    let config_files = source_files
19508        .iter()
19509        .filter(|file| is_planner_config_path(file))
19510        .cloned()
19511        .collect();
19512
19513    let mut semantic_refs = BTreeMap::new();
19514    for kind in ["semantic_concept", "semantic_entity"] {
19515        for (semantic, _) in
19516            graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19517        {
19518            let terse: SubstrateTerseGraphNode = (&semantic).into();
19519            let item = conflict_matrix_semantic_ref(root, &terse);
19520            semantic_refs
19521                .entry(format!("{}:{}", item.kind, item.label))
19522                .or_insert(item);
19523        }
19524    }
19525
19526    Ok(DependencyDagProfile {
19527        id,
19528        graph_node_id: node.id.clone(),
19529        label: node.label.clone(),
19530        path: node.properties.get("path").cloned(),
19531        line: node
19532            .properties
19533            .get("line")
19534            .and_then(|value| value.parse::<i64>().ok()),
19535        detail: node.properties.get("detail").cloned(),
19536        source_files,
19537        source_symbols,
19538        config_files,
19539        expected_tests,
19540        semantic_refs,
19541        worker_feedback,
19542    })
19543}
19544
19545fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19546    let lower = text.to_ascii_lowercase();
19547    let mut refs = Vec::new();
19548    for marker in markers {
19549        let mut offset = 0usize;
19550        while let Some(pos) = lower[offset..].find(marker) {
19551            let start = offset + pos + marker.len();
19552            let segment = text[start..]
19553                .split(['\n', '.'])
19554                .next()
19555                .unwrap_or(&text[start..]);
19556            refs.extend(extract_conflict_target_refs(segment));
19557            offset = start;
19558        }
19559    }
19560    dedupe_preserve_order(refs)
19561}
19562
19563fn dependency_dag_push_edge(
19564    edges: &mut Vec<DependencyDagEdge>,
19565    seen: &mut BTreeSet<(String, String, String)>,
19566    edge: DependencyDagEdge,
19567) {
19568    if edge.from == edge.to {
19569        return;
19570    }
19571    if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19572        edges.push(edge);
19573    }
19574}
19575
19576fn dependency_dag_explicit_edges(
19577    profiles: &[DependencyDagProfile],
19578    target_ids: &BTreeSet<String>,
19579    edges: &mut Vec<DependencyDagEdge>,
19580    seen: &mut BTreeSet<(String, String, String)>,
19581) {
19582    for profile in profiles {
19583        let detail = profile.detail.as_deref().unwrap_or_default();
19584        for dep in dependency_dag_marker_refs(
19585            detail,
19586            &[
19587                "depends on",
19588                "depends-on",
19589                "deps:",
19590                "after",
19591                "blocked by",
19592                "requires",
19593            ],
19594        ) {
19595            if target_ids.contains(&dep) {
19596                dependency_dag_push_edge(
19597                    edges,
19598                    seen,
19599                    DependencyDagEdge {
19600                        from: dep.clone(),
19601                        to: profile.id.clone(),
19602                        kind: "explicit_depends_on".to_string(),
19603                        weight: 1000,
19604                        reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19605                        shared_files: Vec::new(),
19606                        shared_symbols: Vec::new(),
19607                        shared_tests: Vec::new(),
19608                        shared_config_files: Vec::new(),
19609                        shared_semantic_refs: Vec::new(),
19610                    },
19611                );
19612            }
19613        }
19614        for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19615            if target_ids.contains(&downstream) {
19616                dependency_dag_push_edge(
19617                    edges,
19618                    seen,
19619                    DependencyDagEdge {
19620                        from: profile.id.clone(),
19621                        to: downstream.clone(),
19622                        kind: "explicit_before".to_string(),
19623                        weight: 900,
19624                        reasons: vec![format!(
19625                            "{} declares it should run before #{downstream}",
19626                            profile.id
19627                        )],
19628                        shared_files: Vec::new(),
19629                        shared_symbols: Vec::new(),
19630                        shared_tests: Vec::new(),
19631                        shared_config_files: Vec::new(),
19632                        shared_semantic_refs: Vec::new(),
19633                    },
19634                );
19635            }
19636        }
19637    }
19638}
19639
19640fn dependency_dag_worker_follow_up_edges(
19641    profiles: &[DependencyDagProfile],
19642    target_ids: &BTreeSet<String>,
19643    edges: &mut Vec<DependencyDagEdge>,
19644    seen: &mut BTreeSet<(String, String, String)>,
19645) {
19646    for profile in profiles {
19647        for follow_up in &profile.worker_feedback.follow_up_ids {
19648            if target_ids.contains(follow_up) {
19649                dependency_dag_push_edge(
19650                    edges,
19651                    seen,
19652                    DependencyDagEdge {
19653                        from: profile.id.clone(),
19654                        to: follow_up.clone(),
19655                        kind: "worker_result_follow_up".to_string(),
19656                        weight: 700,
19657                        reasons: vec![format!(
19658                            "worker_result for #{} references follow-up #{}",
19659                            profile.id, follow_up
19660                        )],
19661                        shared_files: Vec::new(),
19662                        shared_symbols: Vec::new(),
19663                        shared_tests: Vec::new(),
19664                        shared_config_files: Vec::new(),
19665                        shared_semantic_refs: Vec::new(),
19666                    },
19667                );
19668            }
19669        }
19670    }
19671}
19672
19673fn dependency_dag_overlap_edges(
19674    profiles: &[DependencyDagProfile],
19675    edges: &mut Vec<DependencyDagEdge>,
19676    seen: &mut BTreeSet<(String, String, String)>,
19677) {
19678    for left_idx in 0..profiles.len() {
19679        for right_idx in (left_idx + 1)..profiles.len() {
19680            let left = &profiles[left_idx];
19681            let right = &profiles[right_idx];
19682            let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19683            let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19684            let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19685            let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19686            let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19687            let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19688            let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19689            if shared_files.is_empty()
19690                && shared_symbols.is_empty()
19691                && shared_tests.is_empty()
19692                && shared_config_files.is_empty()
19693                && shared_semantic_refs.is_empty()
19694            {
19695                continue;
19696            }
19697            let kind = if shared_files.is_empty()
19698                && shared_symbols.is_empty()
19699                && shared_tests.is_empty()
19700                && shared_config_files.is_empty()
19701            {
19702                "semantic_relation"
19703            } else {
19704                "shared_resource"
19705            };
19706            let mut reasons = Vec::new();
19707            if !shared_files.is_empty() {
19708                reasons.push(format!("shared files: {}", shared_files.join(", ")));
19709            }
19710            if !shared_symbols.is_empty() {
19711                reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19712            }
19713            if !shared_tests.is_empty() {
19714                reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19715            }
19716            if !shared_config_files.is_empty() {
19717                reasons.push(format!(
19718                    "shared config files: {}",
19719                    shared_config_files.join(", ")
19720                ));
19721            }
19722            if !shared_semantic_refs.is_empty() {
19723                reasons.push(format!(
19724                    "shared semantic refs: {}",
19725                    shared_semantic_refs.join(", ")
19726                ));
19727            }
19728            let weight = shared_files.len() * 100
19729                + shared_config_files.len() * 100
19730                + shared_symbols.len() * 40
19731                + shared_tests.len() * 10
19732                + shared_semantic_refs.len() * 5;
19733            dependency_dag_push_edge(
19734                edges,
19735                seen,
19736                DependencyDagEdge {
19737                    from: left.id.clone(),
19738                    to: right.id.clone(),
19739                    kind: kind.to_string(),
19740                    weight,
19741                    reasons,
19742                    shared_files,
19743                    shared_symbols,
19744                    shared_tests,
19745                    shared_config_files,
19746                    shared_semantic_refs,
19747                },
19748            );
19749        }
19750    }
19751}
19752
19753fn dependency_dag_topo_batches(
19754    targets: &[String],
19755    edges: &[DependencyDagEdge],
19756) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19757    let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19758    let order = targets
19759        .iter()
19760        .enumerate()
19761        .map(|(idx, id)| (id.clone(), idx))
19762        .collect::<BTreeMap<_, _>>();
19763    let mut indegree = targets
19764        .iter()
19765        .map(|id| (id.clone(), 0usize))
19766        .collect::<BTreeMap<_, _>>();
19767    let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19768    let mut seen_pairs = BTreeSet::<(String, String)>::new();
19769    for edge in edges {
19770        if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19771            continue;
19772        }
19773        if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19774            continue;
19775        }
19776        *indegree.entry(edge.to.clone()).or_default() += 1;
19777        outgoing
19778            .entry(edge.from.clone())
19779            .or_default()
19780            .push(edge.to.clone());
19781    }
19782    for values in outgoing.values_mut() {
19783        values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19784        values.dedup();
19785    }
19786
19787    let mut processed = BTreeSet::new();
19788    let mut batches = Vec::new();
19789    loop {
19790        let mut ready = targets
19791            .iter()
19792            .filter(|id| !processed.contains(*id))
19793            .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19794            .cloned()
19795            .collect::<Vec<_>>();
19796        ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19797        if ready.is_empty() {
19798            break;
19799        }
19800        for id in &ready {
19801            processed.insert(id.clone());
19802            for next in outgoing.get(id).into_iter().flatten() {
19803                if let Some(value) = indegree.get_mut(next) {
19804                    *value = value.saturating_sub(1);
19805                }
19806            }
19807        }
19808        batches.push(DependencyDagTopoBatch {
19809            batch: batches.len() + 1,
19810            targets: ready,
19811        });
19812    }
19813
19814    let blocked_nodes = targets
19815        .iter()
19816        .filter(|id| !processed.contains(*id))
19817        .cloned()
19818        .collect::<Vec<_>>();
19819    let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19820    let cycle_edges = edges
19821        .iter()
19822        .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19823        .cloned()
19824        .collect::<Vec<_>>();
19825    (
19826        batches,
19827        DependencyDagCycleDiagnostics {
19828            has_cycles: !blocked_nodes.is_empty(),
19829            blocked_nodes,
19830            cycle_edges,
19831        },
19832    )
19833}
19834
19835fn dependency_dag_replay_commands(
19836    path: &Path,
19837    scope: Option<&str>,
19838    targets: &[String],
19839    depth: usize,
19840    limit: usize,
19841) -> Vec<String> {
19842    let target_args = targets
19843        .iter()
19844        .map(|target| shell_quote(target))
19845        .collect::<Vec<_>>()
19846        .join(" ");
19847    let mut command = format!(
19848        "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19849        shell_quote(path.to_string_lossy().as_ref()),
19850        scope
19851            .map(|scope| format!(" --scope {}", shell_quote(scope)))
19852            .unwrap_or_default(),
19853        depth,
19854        limit
19855    );
19856    if !target_args.is_empty() {
19857        command.push(' ');
19858        command.push_str(&target_args);
19859    }
19860    vec![command]
19861}
19862
19863fn build_dependency_dag_report(
19864    path: &Path,
19865    scope: Option<&str>,
19866    raw_targets: &[String],
19867    depth: usize,
19868    limit: usize,
19869) -> Result<DependencyDagReport> {
19870    let root = lint::resolve_project_root_or_canonical_path(path)?;
19871    write_traversal_graph_store(&root, path, scope)
19872        .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19873    let graph_db = graph_substrate_db_path(&root, scope);
19874    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19875        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19876    let mut warnings = Vec::new();
19877    if let Some(recovery) = store.read_only_recovery() {
19878        warnings.push(graph_db_read_recovery_diagnostic(recovery));
19879    }
19880    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19881    if freshness.fail_closed {
19882        bail!(
19883            "dependency-dag graph projection failed closed: {}; repair: {}",
19884            freshness.diagnostics.join("; "),
19885            graph_db_repair_commands(&root, scope).join("; ")
19886        );
19887    }
19888
19889    let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19890    let graph_nodes = store.all_nodes()?;
19891    let graph_edges = store.all_edges()?;
19892    let graph_nodes_by_id = graph_nodes
19893        .into_iter()
19894        .map(|node| (node.id.clone(), node))
19895        .collect::<BTreeMap<_, _>>();
19896    let profiles = target_nodes
19897        .iter()
19898        .map(|node| {
19899            dependency_dag_node_profile(
19900                &root,
19901                &store,
19902                node,
19903                &graph_nodes_by_id,
19904                &graph_edges,
19905                depth,
19906                limit,
19907            )
19908        })
19909        .collect::<Result<Vec<_>>>()?;
19910    let targets = profiles
19911        .iter()
19912        .map(|profile| profile.id.clone())
19913        .collect::<Vec<_>>();
19914    let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19915
19916    let mut edges = Vec::new();
19917    let mut seen_edges = BTreeSet::new();
19918    dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19919    dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19920    dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19921    edges.sort_by(|left, right| {
19922        left.from
19923            .cmp(&right.from)
19924            .then(left.to.cmp(&right.to))
19925            .then(left.kind.cmp(&right.kind))
19926    });
19927    let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19928
19929    let nodes = profiles
19930        .into_iter()
19931        .map(|profile| DependencyDagNode {
19932            id: profile.id,
19933            graph_node_id: profile.graph_node_id,
19934            label: profile.label,
19935            path: profile.path,
19936            line: profile.line,
19937            detail: profile.detail,
19938            source_files: sorted_set(&profile.source_files),
19939            source_symbols: sorted_set(&profile.source_symbols),
19940            config_files: sorted_set(&profile.config_files),
19941            expected_tests: sorted_set(&profile.expected_tests),
19942            semantic_refs: profile.semantic_refs.into_values().collect(),
19943            worker_feedback: profile.worker_feedback,
19944        })
19945        .collect::<Vec<_>>();
19946    let projection_hashes = freshness
19947        .content_hash
19948        .clone()
19949        .into_iter()
19950        .collect::<Vec<_>>();
19951    let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19952    let repair_commands = graph_db_repair_commands(&root, scope);
19953    let summary = DependencyDagSummary {
19954        nodes: nodes.len(),
19955        edges: edges.len(),
19956        topo_batches: topo_batches.len(),
19957        has_cycles: cycle_diagnostics.has_cycles,
19958    };
19959
19960    Ok(DependencyDagReport {
19961        contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19962        root: root.to_string_lossy().to_string(),
19963        scope: scope.map(str::to_string),
19964        path: path.to_string_lossy().to_string(),
19965        targets,
19966        projection_freshness: freshness,
19967        projection_hashes,
19968        nodes,
19969        edges,
19970        topo_batches,
19971        cycle_diagnostics,
19972        summary,
19973        replay_commands,
19974        repair_commands,
19975        warnings,
19976    })
19977}
19978
19979fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19980    if compact {
19981        println!(
19982            "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19983            report.targets.len(),
19984            report.edges.len(),
19985            report.topo_batches.len(),
19986            report.cycle_diagnostics.has_cycles
19987        );
19988    } else {
19989        println!("Dependency DAG");
19990        println!("  targets: {}", report.targets.join(", "));
19991        println!("  edges:   {}", report.edges.len());
19992        println!("  cycles:  {}", report.cycle_diagnostics.has_cycles);
19993    }
19994    for batch in &report.topo_batches {
19995        println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19996    }
19997    for edge in &report.edges {
19998        println!(
19999            "edge {} -> {} kind:{} weight:{}",
20000            edge.from, edge.to, edge.kind, edge.weight
20001        );
20002        for reason in &edge.reasons {
20003            println!("  reason: {reason}");
20004        }
20005    }
20006    if report.cycle_diagnostics.has_cycles {
20007        println!(
20008            "cycle blocked nodes: {}",
20009            report.cycle_diagnostics.blocked_nodes.join(", ")
20010        );
20011    }
20012    for command in &report.replay_commands {
20013        println!("replay: {command}");
20014    }
20015    for command in &report.repair_commands {
20016        println!("repair: {command}");
20017    }
20018    for warning in &report.warnings {
20019        println!("warning: {warning}");
20020    }
20021}
20022
20023fn cmd_dependency_dag(
20024    path: &Path,
20025    scope: Option<&str>,
20026    raw_targets: &[String],
20027    depth: usize,
20028    limit: usize,
20029    format: OutputFormat,
20030) -> Result<()> {
20031    let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
20032    if format.json_output {
20033        print_json_or_envelope(
20034            &report,
20035            &format,
20036            "dependency-dag",
20037            "topological-planning",
20038            ToolEnvelopeSummary {
20039                text: format!(
20040                    "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
20041                    report.targets.len(),
20042                    report.edges.len(),
20043                    report.topo_batches.len(),
20044                    report.cycle_diagnostics.has_cycles
20045                ),
20046                metrics: vec![
20047                    envelope_metric("targets", report.targets.len()),
20048                    envelope_metric("edges", report.edges.len()),
20049                    envelope_metric("topo_batches", report.topo_batches.len()),
20050                    envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
20051                ],
20052            },
20053            report.cycle_diagnostics.has_cycles,
20054            report.replay_commands.clone(),
20055        )
20056    } else {
20057        print_dependency_dag_human(&report, format.compact);
20058        Ok(())
20059    }
20060}
20061
20062/// Persist a bulky raw log behind an artifact handle and attach it to the
20063/// report, so the bounded digest references the full transcript via a stable
20064/// handle + expansion command instead of losing it (stdin) or relying on
20065/// inlined groups. No-op for small logs or when the artifacts dir is unwritable.
20066fn maybe_attach_log_digest_raw_artifact(
20067    root: &Path,
20068    report: &mut log_digest::LogDigestReport,
20069    input: &str,
20070) -> Result<()> {
20071    if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
20072        return Ok(());
20073    }
20074    let key = format!("logdigest:{}:{}", report.total_lines, input.len());
20075    let artifact_path = root
20076        .join(".tsift/artifacts")
20077        .join(format!("{}.log", stable_handle("logdg", &key)));
20078    let expand = format!(
20079        "tsift log-digest --path {} --input {} --json",
20080        shell_quote(root.to_string_lossy().as_ref()),
20081        shell_quote(artifact_path.to_string_lossy().as_ref())
20082    );
20083    let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
20084    report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
20085        handle: artifact.handle,
20086        path: artifact.path,
20087        bytes: artifact.bytes,
20088        lines: artifact.lines,
20089        expand: artifact.expand,
20090    });
20091    Ok(())
20092}
20093
20094/// Run the log-digest token-savings + false-negative fixture gate: prove the
20095/// digest both compresses raw cargo/pytest/npm/pnpm/agent-doc logs and preserves
20096/// their real signals. With `fail_under`, exits non-zero on any case miss.
20097pub(crate) fn render_log_digest_fixture(
20098    path: &Path,
20099    fixture_path: &Path,
20100    fail_under: bool,
20101    format: OutputFormat,
20102) -> Result<()> {
20103    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20104    let fixture_body = fs::read_to_string(fixture_path)
20105        .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
20106    let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
20107        .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
20108    let report = log_digest::evaluate_fixture(&root, &fixture)?;
20109
20110    if format.json_output {
20111        print_json_or_envelope(
20112            &report,
20113            &format,
20114            "log-digest-fixture",
20115            "report",
20116            ToolEnvelopeSummary {
20117                text: if report.passed {
20118                    format!("log-digest gate passed for {} case(s)", report.total_cases)
20119                } else {
20120                    format!("log-digest gate failed {} case(s)", report.failed_cases)
20121                },
20122                metrics: vec![
20123                    envelope_metric("cases", report.total_cases),
20124                    envelope_metric("failed", report.failed_cases),
20125                    envelope_metric("passed", report.passed),
20126                ],
20127            },
20128            false,
20129            vec![],
20130        )?;
20131    } else {
20132        println!("Log digest fixture gate");
20133        println!("  cases:  {}", report.total_cases);
20134        println!("  failed: {}", report.failed_cases);
20135        println!("  status: {}", if report.passed { "pass" } else { "fail" });
20136        for case in &report.cases {
20137            println!(
20138                "  [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
20139                if case.passed { "pass" } else { "FAIL" },
20140                case.name,
20141                case.ecosystem,
20142                case.savings_percent,
20143                case.minimum_savings_percent,
20144                case.raw_tokens,
20145                case.digest_tokens
20146            );
20147            if !case.missing_required_signals.is_empty() {
20148                println!(
20149                    "    missing required signals: {}",
20150                    case.missing_required_signals.join(", ")
20151                );
20152            }
20153            if !case.present_forbidden_signals.is_empty() {
20154                println!(
20155                    "    present forbidden signals: {}",
20156                    case.present_forbidden_signals.join(", ")
20157                );
20158            }
20159        }
20160    }
20161
20162    if fail_under && !report.passed {
20163        bail!("log-digest fixture gate failed");
20164    }
20165    Ok(())
20166}
20167
20168pub(crate) fn render_log_digest_from_input(
20169    path: &Path,
20170    input: &str,
20171    format: OutputFormat,
20172) -> Result<()> {
20173    let mut report = log_digest::compute(path, input)?;
20174    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20175    maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
20176    if format.json_output {
20177        println!(
20178            "{}",
20179            to_json_schema(
20180                &report,
20181                format.pretty,
20182                format.terse,
20183                format.ultra_terse,
20184                format.schema
20185            )?
20186        );
20187        return Ok(());
20188    }
20189
20190    if format.compact {
20191        println!(
20192            "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
20193            report.non_empty_lines,
20194            report.signal_groups,
20195            report.repeated_line_groups,
20196            report.file_ref_groups,
20197            report.symbol_ref_groups,
20198            report.stack_groups
20199        );
20200        for signal in &report.signals {
20201            let location = match (&signal.path, signal.line) {
20202                (Some(path), Some(line)) => format!("{path}:{line}"),
20203                (Some(path), None) => path.clone(),
20204                _ => "-".to_string(),
20205            };
20206            println!(
20207                "{} sev:{} count:{} sums:{} msg:{}",
20208                location,
20209                signal.severity,
20210                signal.occurrences,
20211                log_digest_summary_label(signal.summary_state),
20212                truncate_for_compact(&signal.message, 80)
20213            );
20214        }
20215        for repeated in &report.repeated_lines {
20216            println!(
20217                "repeat count:{} line:{}",
20218                repeated.occurrences,
20219                truncate_for_compact(&repeated.line, 80)
20220            );
20221        }
20222        for family in &report.line_families {
20223            println!(
20224                "family count:{} variants:{} template:{}",
20225                family.occurrences,
20226                family.variants,
20227                truncate_for_compact(&family.template, 80)
20228            );
20229        }
20230        for symbol in &report.symbol_refs {
20231            println!(
20232                "sym:{} count:{} sums:{}",
20233                symbol.symbol,
20234                symbol.occurrences,
20235                log_digest_summary_label(symbol.summary_state)
20236            );
20237        }
20238        if let Some(artifact) = &report.raw_log_artifact {
20239            println!(
20240                "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
20241                artifact.handle, artifact.lines, artifact.bytes, artifact.expand
20242            );
20243        }
20244        for warning in &report.warnings {
20245            println!("warning: {warning}");
20246        }
20247        return Ok(());
20248    }
20249
20250    println!("Log digest");
20251    println!("  lines:                    {}", report.total_lines);
20252    println!("  non-empty lines:          {}", report.non_empty_lines);
20253    println!("  signal groups:            {}", report.signal_groups);
20254    println!(
20255        "  repeated lines:           {}",
20256        report.repeated_line_groups
20257    );
20258    println!(
20259        "  repeated line instances:  {}",
20260        report.repeated_line_occurrences
20261    );
20262    println!("  line families:            {}", report.line_family_groups);
20263    println!("  file refs:                {}", report.file_ref_groups);
20264    println!("  symbol refs:              {}", report.symbol_ref_groups);
20265    println!("  stack groups:             {}", report.stack_groups);
20266
20267    if !report.signals.is_empty() {
20268        println!();
20269        println!("Signals:");
20270        for signal in &report.signals {
20271            match (&signal.path, signal.line, signal.column) {
20272                (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
20273                (Some(path), Some(line), None) => println!("{path}:{line}"),
20274                (Some(path), None, _) => println!("{path}"),
20275                (None, _, _) => println!("(no file anchor)"),
20276            }
20277            println!("  severity: {}", signal.severity);
20278            println!("  occurrences: {}", signal.occurrences);
20279            println!("  message: {}", signal.message);
20280            println!(
20281                "  cached summaries: {}",
20282                log_digest_summary_label(signal.summary_state)
20283            );
20284            for summary in &signal.current_summaries {
20285                println!(
20286                    "    - {}: {}",
20287                    summary.symbol,
20288                    truncate_for_compact(&summary.summary, 160)
20289                );
20290            }
20291        }
20292    }
20293
20294    if !report.repeated_lines.is_empty() {
20295        println!();
20296        println!("Repeated lines:");
20297        for repeated in &report.repeated_lines {
20298            println!(
20299                "  {}x {}",
20300                repeated.occurrences,
20301                truncate_for_compact(&repeated.line, 180)
20302            );
20303        }
20304    }
20305
20306    if !report.line_families.is_empty() {
20307        println!();
20308        println!("Line families (near-duplicate folds):");
20309        for family in &report.line_families {
20310            println!(
20311                "  {}x ({} variants) {}",
20312                family.occurrences,
20313                family.variants,
20314                truncate_for_compact(&family.template, 180)
20315            );
20316            println!(
20317                "    first: {}",
20318                truncate_for_compact(&family.first_sample, 180)
20319            );
20320            println!(
20321                "    last:  {}",
20322                truncate_for_compact(&family.last_sample, 180)
20323            );
20324        }
20325    }
20326
20327    if !report.file_refs.is_empty() {
20328        println!();
20329        println!("Anchored files:");
20330        for file_ref in &report.file_refs {
20331            match (file_ref.line, file_ref.column) {
20332                (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
20333                (Some(line), None) => println!("{}:{}", file_ref.path, line),
20334                (None, _) => println!("{}", file_ref.path),
20335            }
20336            println!("  occurrences: {}", file_ref.occurrences);
20337            println!(
20338                "  cached summaries: {}",
20339                log_digest_summary_label(file_ref.summary_state)
20340            );
20341            for summary in &file_ref.current_summaries {
20342                println!(
20343                    "    - {}: {}",
20344                    summary.symbol,
20345                    truncate_for_compact(&summary.summary, 160)
20346                );
20347            }
20348        }
20349    }
20350
20351    if !report.symbol_refs.is_empty() {
20352        println!();
20353        println!("Symbol candidates:");
20354        for symbol in &report.symbol_refs {
20355            println!("{}", symbol.symbol);
20356            println!("  occurrences: {}", symbol.occurrences);
20357            println!(
20358                "  cached summaries: {}",
20359                log_digest_summary_label(symbol.summary_state)
20360            );
20361            for summary in &symbol.current_summaries {
20362                println!(
20363                    "    - {}: {}",
20364                    summary.symbol,
20365                    truncate_for_compact(&summary.summary, 160)
20366                );
20367            }
20368        }
20369    }
20370
20371    if !report.stack_traces.is_empty() {
20372        println!();
20373        println!("Stack groups:");
20374        for stack in &report.stack_traces {
20375            println!("  occurrences: {}", stack.occurrences);
20376            for frame in &stack.frames {
20377                println!("    - {}", frame);
20378            }
20379        }
20380    }
20381
20382    if let Some(artifact) = &report.raw_log_artifact {
20383        println!();
20384        println!("Raw log artifact:");
20385        println!("  handle: {}", artifact.handle);
20386        println!("  path:   {}", artifact.path);
20387        println!("  lines:  {}", artifact.lines);
20388        println!("  bytes:  {}", artifact.bytes);
20389        println!("  expand: {}", artifact.expand);
20390    }
20391
20392    for warning in &report.warnings {
20393        println!("warning: {warning}");
20394    }
20395    Ok(())
20396}
20397
20398pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
20399    match trend {
20400        metric_digest::MetricDigestTrend::Improved => "improved",
20401        metric_digest::MetricDigestTrend::Regressed => "regressed",
20402        metric_digest::MetricDigestTrend::Flat => "flat",
20403        metric_digest::MetricDigestTrend::Unknown => "changed",
20404    }
20405}
20406
20407pub(crate) fn metric_digest_gate_label(
20408    decision: metric_digest::CommunitySearchGateDecision,
20409) -> &'static str {
20410    match decision {
20411        metric_digest::CommunitySearchGateDecision::Pass => "pass",
20412        metric_digest::CommunitySearchGateDecision::Block => "block",
20413    }
20414}
20415
20416pub(crate) fn memgraphrag_metric_digest_gate_label(
20417    decision: metric_digest::MemGraphRagPerformanceGateDecision,
20418) -> &'static str {
20419    match decision {
20420        metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
20421        metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
20422    }
20423}
20424
20425fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
20426    let input = fs::read_to_string(fixture_path)
20427        .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
20428    let report = dci_benchmark::compute(&input)?;
20429
20430    if format.json_output {
20431        println!(
20432            "{}",
20433            to_json_schema(
20434                &report,
20435                format.pretty,
20436                format.terse,
20437                format.ultra_terse,
20438                format.schema
20439            )?
20440        );
20441        return Ok(());
20442    }
20443
20444    if format.compact {
20445        println!(
20446            "dci tasks:{} strategies:{} warnings:{}",
20447            report.tasks_loaded,
20448            report.strategies_compared,
20449            report.warnings.len()
20450        );
20451        for summary in &report.strategy_summaries {
20452            println!(
20453                "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
20454                summary.strategy,
20455                summary.rank,
20456                summary.localized,
20457                summary.task_runs,
20458                dci_benchmark::format_number(summary.localization_rate * 100.0),
20459                dci_benchmark::format_number(summary.avg_useful_hits),
20460                dci_benchmark::format_number(summary.zero_output_rate * 100.0),
20461                dci_benchmark::format_number(summary.avg_tool_calls),
20462                dci_benchmark::format_number(summary.avg_latency_ms),
20463                dci_benchmark::format_number(summary.avg_estimated_tokens),
20464                dci_benchmark::format_number(summary.avg_output_tokens)
20465            );
20466        }
20467        if let Some(gate) = &report.memory_retrieval_gate {
20468            println!(
20469                "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
20470                gate.decision,
20471                gate.baseline_strategy,
20472                dci_benchmark::format_number(gate.min_avg_useful_hits),
20473                gate.max_zero_output_failures,
20474                gate.diagnostics.len()
20475            );
20476        }
20477        for warning in &report.warnings {
20478            println!("warning: {warning}");
20479        }
20480        return Ok(());
20481    }
20482
20483    println!("DCI benchmark");
20484    if let Some(description) = &report.description {
20485        println!("  description: {}", description);
20486    }
20487    println!("  tasks loaded:        {}", report.tasks_loaded);
20488    println!("  strategies compared: {}", report.strategies_compared);
20489
20490    println!();
20491    println!("Strategy summary:");
20492    for summary in &report.strategy_summaries {
20493        println!(
20494            "  #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
20495            summary.rank,
20496            summary.strategy,
20497            summary.localized,
20498            summary.task_runs,
20499            summary.localization_rate * 100.0,
20500            dci_benchmark::format_number(summary.avg_useful_hits),
20501            summary.zero_output_rate * 100.0,
20502            dci_benchmark::format_number(summary.avg_tool_calls),
20503            dci_benchmark::format_number(summary.avg_latency_ms),
20504            dci_benchmark::format_number(summary.avg_estimated_tokens),
20505            dci_benchmark::format_number(summary.avg_output_tokens)
20506        );
20507    }
20508
20509    if let Some(gate) = &report.memory_retrieval_gate {
20510        println!();
20511        println!("Memory retrieval gate:");
20512        println!("  decision: {}", gate.decision);
20513        println!(
20514            "  baseline: {}, min avg useful hits {}, max zero-output failures {}",
20515            gate.baseline_strategy,
20516            dci_benchmark::format_number(gate.min_avg_useful_hits),
20517            gate.max_zero_output_failures
20518        );
20519        for row in &gate.rows {
20520            println!(
20521                "  {}: status {}, avg useful hits {}, zero-output failures {}",
20522                row.strategy,
20523                row.status,
20524                dci_benchmark::format_number(row.avg_useful_hits),
20525                row.zero_output_failures
20526            );
20527        }
20528        for diagnostic in &gate.diagnostics {
20529            println!("  diagnostic: {diagnostic}");
20530        }
20531    }
20532
20533    println!();
20534    println!("Task winners:");
20535    for row in &report.task_rows {
20536        let label = row
20537            .label
20538            .as_ref()
20539            .map(|value| format!(" ({value})"))
20540            .unwrap_or_default();
20541        println!("  {}{}", row.task_id, label);
20542        println!("    localized: {}", row.best_localization.join(", "));
20543        println!("    most useful hits: {}", row.most_useful_hits.join(", "));
20544        println!(
20545            "    lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
20546            row.lowest_tool_calls.as_deref().unwrap_or("-"),
20547            row.lowest_latency.as_deref().unwrap_or("-"),
20548            row.lowest_token_budget.as_deref().unwrap_or("-"),
20549            row.lowest_output_tokens.as_deref().unwrap_or("-")
20550        );
20551        if !row.zero_output_failures.is_empty() {
20552            println!("    zero output: {}", row.zero_output_failures.join(", "));
20553        }
20554    }
20555
20556    for warning in &report.warnings {
20557        println!("warning: {warning}");
20558    }
20559    Ok(())
20560}
20561
20562pub(crate) fn format_compact_count(value: u64) -> String {
20563    if value >= 1_000_000 {
20564        format!("{:.1}M", value as f64 / 1_000_000.0)
20565    } else if value >= 1_000 {
20566        format!("{:.1}K", value as f64 / 1_000.0)
20567    } else {
20568        value.to_string()
20569    }
20570}
20571
20572fn cmd_digest_runner(
20573    kind: &str,
20574    path: &Path,
20575    runner: Option<&str>,
20576    shell_command: &str,
20577    format: OutputFormat,
20578) -> Result<()> {
20579    let digest_kind = DigestRunnerKind::parse(kind)?;
20580    let root = transcript_artifact_root(path)?;
20581    let execution = run_digest_runner_command(shell_command)?;
20582    let output = &execution.output;
20583    let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20584    let exit_code = output.status.code().unwrap_or(-1);
20585    if format.json_output && format.envelope {
20586        let artifact_key = format!(
20587            "{}:{}:{}:{}",
20588            digest_kind.as_str(),
20589            shell_command,
20590            execution.executed_command,
20591            captured
20592        );
20593        let artifact = if captured.trim().is_empty() {
20594            None
20595        } else {
20596            let (suffix, expand) = match digest_kind {
20597                DigestRunnerKind::Test => (
20598                    "test.log",
20599                    format!(
20600                        "tsift test-digest --path {} --input {}{} --json",
20601                        shell_quote(root.to_string_lossy().as_ref()),
20602                        shell_quote(
20603                            root.join(".tsift/artifacts")
20604                                .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20605                                .to_string_lossy()
20606                                .as_ref()
20607                        ),
20608                        runner
20609                            .map(|value| format!(" --runner {}", shell_quote(value)))
20610                            .unwrap_or_default()
20611                    ),
20612                ),
20613                DigestRunnerKind::Log => (
20614                    "log",
20615                    format!(
20616                        "tsift log-digest --path {} --input {} --json",
20617                        shell_quote(root.to_string_lossy().as_ref()),
20618                        shell_quote(
20619                            root.join(".tsift/artifacts")
20620                                .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20621                                .to_string_lossy()
20622                                .as_ref()
20623                        )
20624                    ),
20625                ),
20626            };
20627            Some(persist_transcript_artifact(
20628                &root,
20629                "tart",
20630                suffix,
20631                &artifact_key,
20632                &captured,
20633                expand,
20634            )?)
20635        };
20636        let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20637
20638        match digest_kind {
20639            DigestRunnerKind::Test => {
20640                let digest_report = test_digest::compute(path, &captured, runner)?;
20641                let report = serde_json::json!({
20642                    "kind": digest_kind.as_str(),
20643                    "command": shell_command,
20644                    "executed_command": execution.executed_command,
20645                    "exit_code": exit_code,
20646                    "success": output.status.success(),
20647                    "filter": filter_report,
20648                    "artifact": artifact,
20649                    "digest": digest_report,
20650                });
20651                let mut follow_up = artifact
20652                    .as_ref()
20653                    .map(|entry| vec![entry.expand.clone()])
20654                    .unwrap_or_default();
20655                follow_up.push(format!(
20656                    "tsift rewrite --run {}",
20657                    shell_quote(shell_command)
20658                ));
20659                let summary_text = if output.status.success() && digest_report.failures == 0 {
20660                    format!("test run passed for {}", runner.unwrap_or("auto"))
20661                } else {
20662                    format!("test run captured {} failure(s)", digest_report.failures)
20663                };
20664                print_json_or_envelope(
20665                    &report,
20666                    &format,
20667                    "digest-runner",
20668                    "test-run",
20669                    ToolEnvelopeSummary {
20670                        text: summary_text,
20671                        metrics: vec![
20672                            envelope_metric("runner", &digest_report.runner),
20673                            envelope_metric("exit_code", exit_code),
20674                            envelope_metric("filter", execution.filter_label()),
20675                            envelope_metric("failures", digest_report.failures),
20676                            envelope_metric("groups", digest_report.grouped_failures),
20677                            envelope_metric(
20678                                "artifact",
20679                                artifact
20680                                    .as_ref()
20681                                    .map(|entry| entry.handle.as_str())
20682                                    .unwrap_or("-"),
20683                            ),
20684                        ],
20685                    },
20686                    false,
20687                    follow_up,
20688                )?;
20689            }
20690            DigestRunnerKind::Log => {
20691                let digest_report = log_digest::compute(path, &captured)?;
20692                let report = serde_json::json!({
20693                    "kind": digest_kind.as_str(),
20694                    "command": shell_command,
20695                    "executed_command": execution.executed_command,
20696                    "exit_code": exit_code,
20697                    "success": output.status.success(),
20698                    "filter": filter_report,
20699                    "artifact": artifact,
20700                    "digest": digest_report,
20701                });
20702                let mut follow_up = artifact
20703                    .as_ref()
20704                    .map(|entry| vec![entry.expand.clone()])
20705                    .unwrap_or_default();
20706                follow_up.push(format!(
20707                    "tsift rewrite --run {}",
20708                    shell_quote(shell_command)
20709                ));
20710                let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20711                    "command finished without log signals".to_string()
20712                } else {
20713                    format!(
20714                        "command emitted {} log signal group(s)",
20715                        digest_report.signal_groups
20716                    )
20717                };
20718                print_json_or_envelope(
20719                    &report,
20720                    &format,
20721                    "digest-runner",
20722                    "command-run",
20723                    ToolEnvelopeSummary {
20724                        text: summary_text,
20725                        metrics: vec![
20726                            envelope_metric("exit_code", exit_code),
20727                            envelope_metric("filter", execution.filter_label()),
20728                            envelope_metric("signals", digest_report.signal_groups),
20729                            envelope_metric("file_refs", digest_report.file_ref_groups),
20730                            envelope_metric(
20731                                "artifact",
20732                                artifact
20733                                    .as_ref()
20734                                    .map(|entry| entry.handle.as_str())
20735                                    .unwrap_or("-"),
20736                            ),
20737                        ],
20738                    },
20739                    false,
20740                    follow_up,
20741                )?;
20742            }
20743        }
20744
20745        if output.status.success() {
20746            return Ok(());
20747        }
20748        if let Some(code) = output.status.code() {
20749            std::process::exit(code);
20750        }
20751        bail!("digest-wrapped command terminated by signal: {shell_command}");
20752    }
20753
20754    if captured.trim().is_empty() {
20755        let label = match digest_kind {
20756            DigestRunnerKind::Test => "test",
20757            DigestRunnerKind::Log => "log",
20758        };
20759        println!("No {label} output captured.");
20760    } else {
20761        match digest_kind {
20762            DigestRunnerKind::Test => {
20763                render_test_digest_from_input(path, &captured, runner, format)?
20764            }
20765            DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20766        }
20767    }
20768
20769    if output.status.success() {
20770        return Ok(());
20771    }
20772    if let Some(code) = output.status.code() {
20773        std::process::exit(code);
20774    }
20775    bail!("digest-wrapped command terminated by signal: {shell_command}");
20776}
20777
20778struct DigestRunnerExecution {
20779    output: std::process::Output,
20780    executed_command: String,
20781    filter: Option<DigestRunnerFilter>,
20782}
20783
20784impl DigestRunnerExecution {
20785    fn filter_label(&self) -> &'static str {
20786        self.filter
20787            .as_ref()
20788            .map(|filter| filter.tool)
20789            .unwrap_or("none")
20790    }
20791}
20792
20793struct DigestRunnerFilter {
20794    tool: &'static str,
20795    command: String,
20796}
20797
20798impl DigestRunnerFilter {
20799    fn to_json(&self) -> serde_json::Value {
20800        serde_json::json!({
20801            "tool": self.tool,
20802            "command": self.command,
20803        })
20804    }
20805}
20806
20807fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20808    let filter = rtk_rewrite_for_digest_runner(shell_command);
20809    let executed_command = filter
20810        .as_ref()
20811        .map(|filter| filter.command.as_str())
20812        .unwrap_or(shell_command);
20813    let output = Command::new("sh")
20814        .arg("-lc")
20815        .arg(format!("({executed_command}) 2>&1"))
20816        .stdout(Stdio::piped())
20817        .output()
20818        .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20819
20820    Ok(DigestRunnerExecution {
20821        output,
20822        executed_command: executed_command.to_string(),
20823        filter,
20824    })
20825}
20826
20827fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20828    if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20829        return None;
20830    }
20831    let output = Command::new("rtk")
20832        .arg("rewrite")
20833        .arg(shell_command)
20834        .output()
20835        .ok()?;
20836    if !output.status.success() {
20837        return None;
20838    }
20839    let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20840    if rewritten.is_empty() || rewritten == shell_command {
20841        return None;
20842    }
20843    Some(DigestRunnerFilter {
20844        tool: "rtk",
20845        command: rewritten,
20846    })
20847}
20848
20849fn find_command_on_path(command: &str) -> Option<PathBuf> {
20850    let path_var = std::env::var_os("PATH")?;
20851    std::env::split_paths(&path_var)
20852        .map(|dir| dir.join(command))
20853        .find(|candidate| candidate.is_file())
20854}
20855
20856pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20857    if !db_path.exists() {
20858        bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20859    }
20860    summarize::SummaryDb::open_read_only_resilient(db_path)
20861}
20862
20863fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20864    !matches!(report.index, status::IndexStatus::Fresh { .. })
20865}
20866
20867fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20868    !matches!(report.instructions, init::InstructionStatus::Current { .. })
20869}
20870
20871pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20872    if status_instructions_need_fix(report) {
20873        eprintln!("status fix: refreshing tsift instructions");
20874        init::init(root, false, false)?;
20875    }
20876
20877    let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20878        root,
20879        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20880        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20881    );
20882    if eviction.evicted_entries > 0 {
20883        eprintln!(
20884            "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20885            eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
20886        );
20887    }
20888
20889    if !status_index_needs_fix(report) {
20890        return Ok(());
20891    }
20892
20893    let scopes = config::Config::submodule_dirs(root)?;
20894    if scopes.is_empty() {
20895        eprintln!("status fix: refreshing index");
20896        run_index_update(
20897            &root.join(".tsift/index.db"),
20898            root,
20899            "status --fix refreshing index".to_string(),
20900            root,
20901            None,
20902            false,
20903            false,
20904        )?;
20905        return Ok(());
20906    }
20907
20908    let cfg = config::Config::load(root)?;
20909    for scope in scopes {
20910        if !scope.source_root.exists() {
20911            eprintln!(
20912                "status fix: skipping missing submodule `{}` ({})",
20913                scope.id,
20914                scope.source_root.display()
20915            );
20916            continue;
20917        }
20918        eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20919        run_index_update(
20920            &cfg.db_path_for(root, &scope.id),
20921            &scope.source_root,
20922            format!("status --fix refreshing submodule `{}` index", scope.id),
20923            root,
20924            Some(scope.id.as_str()),
20925            false,
20926            false,
20927        )?;
20928    }
20929
20930    Ok(())
20931}
20932
20933pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20934    match &report.index {
20935        status::IndexStatus::Fresh { missing_scopes, .. }
20936        | status::IndexStatus::Stale { missing_scopes, .. }
20937        | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20938    }
20939}
20940
20941pub(crate) fn autoindex_missing_workspace_scopes(
20942    root: &Path,
20943    report: &status::StatusReport,
20944) -> Result<()> {
20945    let missing_scopes = match &report.index {
20946        status::IndexStatus::Fresh { missing_scopes, .. }
20947        | status::IndexStatus::Stale { missing_scopes, .. }
20948        | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20949    };
20950    if missing_scopes.is_empty() {
20951        return Ok(());
20952    }
20953
20954    let missing_scope_ids = missing_scopes
20955        .iter()
20956        .map(|scope| scope.scope.as_str())
20957        .collect::<std::collections::HashSet<_>>();
20958    let cfg = config::Config::load(root)?;
20959    for scope in config::Config::submodule_dirs(root)? {
20960        if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20961            continue;
20962        }
20963        let db_path = cfg.db_path_for(root, &scope.id);
20964        run_index_update(
20965            &db_path,
20966            &scope.source_root,
20967            format!(
20968                "autoindexing missing submodule `{}` during status",
20969                scope.id
20970            ),
20971            root,
20972            Some(scope.id.as_str()),
20973            false,
20974            false,
20975        )?;
20976    }
20977    Ok(())
20978}
20979
20980pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20981    for warning in &stats.warnings {
20982        let rel_path = relativize_pathbuf(&warning.path, root);
20983        eprintln!(
20984            "warning: summarize stats {}: {}",
20985            rel_path.display(),
20986            warning.message
20987        );
20988    }
20989}
20990
20991fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20992    Result::<(), anyhow::Error>::Err(err)
20993        .context(context)
20994        .unwrap_err()
20995}
20996
20997fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20998    let message = err.to_string();
20999    message.contains("another tsift index writer is already active")
21000        || substrate::error_mentions_locked_db(err)
21001}
21002
21003fn add_write_lock_context(
21004    err: anyhow::Error,
21005    action: String,
21006    root: &std::path::Path,
21007    scope: Option<&str>,
21008) -> anyhow::Error {
21009    if !should_attach_lock_diagnostics(&err) {
21010        return contextualize_error(err, action);
21011    }
21012
21013    let Ok(report) = status::check_locks(root, None, scope) else {
21014        return contextualize_error(err, action);
21015    };
21016
21017    contextualize_error(
21018        err,
21019        format!(
21020            "{}\n\nlock diagnostics:\n{}",
21021            action,
21022            status::format_locks_human(&report, false).trim_end()
21023        ),
21024    )
21025}
21026
21027pub(crate) fn run_index_update(
21028    db_path: &std::path::Path,
21029    source_root: &std::path::Path,
21030    action: String,
21031    root: &std::path::Path,
21032    scope: Option<&str>,
21033    rebuild: bool,
21034    prune: bool,
21035) -> Result<index::IndexSummary> {
21036    let result = (|| {
21037        let db = index::IndexDb::open(db_path)?;
21038        if rebuild {
21039            db.rebuild(source_root)
21040        } else if prune {
21041            db.apply_changes_pruned(source_root)
21042        } else {
21043            db.apply_changes(source_root)
21044        }
21045    })();
21046
21047    let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
21048    emit_index_warnings(&summary, source_root, scope);
21049    Ok(summary)
21050}
21051
21052pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
21053    for change in &mut summary.changes {
21054        change.path = relativize_pathbuf(&change.path, root);
21055    }
21056    for warning in &mut summary.warnings {
21057        warning.path = relativize_pathbuf(&warning.path, root);
21058    }
21059}
21060
21061fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
21062    for warning in &summary.warnings {
21063        let rel_path = relativize_pathbuf(&warning.path, root);
21064        let stage = match warning.stage {
21065            index::IndexWarningStage::ReadSource => "read failed",
21066            index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
21067            index::IndexWarningStage::ExtractCallSites => "call extraction failed",
21068            index::IndexWarningStage::ExtractRoutes => "route extraction failed",
21069        };
21070        let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
21071        let lang_suffix = warning
21072            .language
21073            .as_deref()
21074            .map(|lang| format!(" [{}]", lang))
21075            .unwrap_or_default();
21076        eprintln!(
21077            "warning: {}{}{}: {}: {}",
21078            scope_prefix,
21079            rel_path.display(),
21080            lang_suffix,
21081            stage,
21082            warning.message
21083        );
21084    }
21085}
21086
21087pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
21088    let config_path = root.join(".tsift/config.toml");
21089    if !config_path.exists() {
21090        return summarize::SummarizeConfig::default();
21091    }
21092    #[derive(serde::Deserialize, Default)]
21093    struct RawConfig {
21094        #[serde(default)]
21095        summarize: Option<RawSummarize>,
21096    }
21097    #[derive(serde::Deserialize)]
21098    struct RawSummarize {
21099        model: Option<String>,
21100        max_file_tokens: Option<usize>,
21101        api_key_env: Option<String>,
21102    }
21103    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
21104    let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
21105    let defaults = summarize::SummarizeConfig::default();
21106    match raw.summarize {
21107        Some(s) => summarize::SummarizeConfig {
21108            model: s.model.unwrap_or(defaults.model),
21109            max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
21110            api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
21111        },
21112        None => defaults,
21113    }
21114}
21115
21116#[derive(Debug, Clone, PartialEq, Eq)]
21117struct ExtractSymbolContext {
21118    db_path: PathBuf,
21119    source_root: PathBuf,
21120}
21121
21122pub(crate) fn find_symbols_db_for_file(
21123    root: &Path,
21124    file_path: &Path,
21125) -> Result<Option<ExtractSymbolContext>> {
21126    let cfg = config::Config::load(root)?;
21127    let mut submodules = config::Config::submodule_dirs(root)?;
21128    submodules.sort_by(|left, right| {
21129        right
21130            .source_root
21131            .components()
21132            .count()
21133            .cmp(&left.source_root.components().count())
21134    });
21135
21136    for scope in submodules {
21137        if !file_path.starts_with(&scope.source_root) {
21138            continue;
21139        }
21140        let db_path = cfg.db_path_for(root, &scope.id);
21141        if db_path.exists() {
21142            return Ok(Some(ExtractSymbolContext {
21143                db_path,
21144                source_root: scope.source_root,
21145            }));
21146        }
21147    }
21148
21149    let single = root.join(".tsift/index.db");
21150    if single.exists() && file_path.starts_with(root) {
21151        return Ok(Some(ExtractSymbolContext {
21152            db_path: single,
21153            source_root: root.to_path_buf(),
21154        }));
21155    }
21156
21157    Ok(None)
21158}
21159
21160pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
21161    let canonical = path
21162        .canonicalize()
21163        .with_context(|| format!("canonicalizing {}", path.display()))?;
21164
21165    Ok(if canonical.is_dir() {
21166        canonical
21167    } else {
21168        canonical
21169            .parent()
21170            .map(Path::to_path_buf)
21171            .unwrap_or(canonical)
21172    })
21173}
21174
21175fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
21176    if path.exists() {
21177        return path
21178            .canonicalize()
21179            .with_context(|| format!("canonicalizing extract scope {}", path.display()));
21180    }
21181
21182    Ok(summarize::normalize_lexical_path(path))
21183}
21184
21185pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
21186    let scope = if extract_path.is_absolute() {
21187        extract_path.to_path_buf()
21188    } else {
21189        root.join(extract_path)
21190    };
21191    normalize_extract_scope_path(&scope)
21192}
21193
21194pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
21195    normalize_extract_scope_path(changed_path)
21196        .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
21197        .starts_with(extract_scope)
21198}
21199
21200pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
21201    summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
21202}
21203
21204pub(crate) fn summarize_full_extract_deleted_summary_paths(
21205    summary_db: &summarize::SummaryDb,
21206    root: &Path,
21207    extract_scope: &Path,
21208    files_to_extract: &[PathBuf],
21209) -> Result<BTreeSet<String>> {
21210    let live_paths = files_to_extract
21211        .iter()
21212        .map(|file_path| summarize_relative_file_path(root, file_path))
21213        .collect::<BTreeSet<_>>();
21214    let mut deleted = BTreeSet::new();
21215
21216    for cached_path in summary_db.cached_file_paths()? {
21217        if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
21218            continue;
21219        }
21220        if !live_paths.contains(&cached_path) {
21221            deleted.insert(cached_path);
21222        }
21223    }
21224
21225    Ok(deleted)
21226}
21227
21228#[derive(Debug, Clone)]
21229struct SearchIndexTarget {
21230    label: String,
21231    db_path: PathBuf,
21232    source_root: PathBuf,
21233    scope_name: Option<String>,
21234    reindex_cmd: String,
21235}
21236
21237fn cargo_package_index_target(
21238    root: &Path,
21239    package: multiplicity::CargoPackageInfo,
21240) -> SearchIndexTarget {
21241    SearchIndexTarget {
21242        label: format!("cargo package `{}` index", package.scope_id),
21243        db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
21244        source_root: package.package_root.clone(),
21245        scope_name: Some(package.scope_id.clone()),
21246        reindex_cmd: format!(
21247            "tsift index --submodule {} {}",
21248            package.scope_id,
21249            root.display()
21250        ),
21251    }
21252}
21253
21254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21255enum SearchIndexState {
21256    Missing,
21257    Fresh,
21258    Stale { stale_files: usize },
21259}
21260
21261fn resolve_search_index_targets(
21262    root: &Path,
21263    path_hint: &Path,
21264    scope: Option<&str>,
21265    federated: bool,
21266) -> Result<Vec<SearchIndexTarget>> {
21267    if let Some(scope_name) = scope {
21268        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
21269            let cfg = config::Config::load(root)?;
21270            return Ok(vec![SearchIndexTarget {
21271                label: format!("submodule `{}` index", scope.id),
21272                db_path: cfg.db_path_for(root, &scope.id),
21273                source_root: scope.source_root.clone(),
21274                scope_name: Some(scope.id.clone()),
21275                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21276            }]);
21277        }
21278        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
21279            return Ok(vec![cargo_package_index_target(root, package)]);
21280        }
21281        config::Config::resolve_submodule(root, scope_name)?;
21282    }
21283
21284    if federated {
21285        let cfg = config::Config::load(root)?;
21286        let mut targets = Vec::new();
21287        for scope in config::Config::submodule_dirs(root)? {
21288            if !cfg.federation_for_scope(&scope) {
21289                continue;
21290            }
21291            targets.push(SearchIndexTarget {
21292                label: format!("submodule `{}` index", scope.id),
21293                db_path: cfg.db_path_for(root, &scope.id),
21294                source_root: scope.source_root.clone(),
21295                scope_name: Some(scope.id.clone()),
21296                reindex_cmd: format!("tsift index --workspace {}", root.display()),
21297            });
21298        }
21299        return Ok(targets);
21300    }
21301
21302    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
21303        let cfg = config::Config::load(root)?;
21304        return Ok(vec![SearchIndexTarget {
21305            label: format!("submodule `{}` index", scope.id),
21306            db_path: cfg.db_path_for(root, &scope.id),
21307            source_root: scope.source_root.clone(),
21308            scope_name: Some(scope.id.clone()),
21309            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21310        }]);
21311    }
21312
21313    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
21314        return Ok(vec![cargo_package_index_target(root, package)]);
21315    }
21316
21317    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
21318        let cfg = config::Config::load(root)?;
21319        return Ok(vec![SearchIndexTarget {
21320            label: format!("submodule `{}` index", scope.id),
21321            db_path: cfg.db_path_for(root, &scope.id),
21322            source_root: scope.source_root.clone(),
21323            scope_name: Some(scope.id.clone()),
21324            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21325        }]);
21326    }
21327
21328    let scopes = config::Config::submodule_dirs(root)?;
21329    if !scopes.is_empty() {
21330        let root_db = root.join(".tsift/index.db");
21331        if !root_db.exists() {
21332            let available_scopes = scopes
21333                .iter()
21334                .map(|scope| scope.id.as_str())
21335                .collect::<Vec<_>>()
21336                .join(", ");
21337            let cfg = config::Config::load(root)?;
21338            let indexed_scopes = scopes
21339                .iter()
21340                .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
21341                .map(|scope| scope.id.as_str())
21342                .collect::<Vec<_>>();
21343            let indexed_label = if indexed_scopes.is_empty() {
21344                "none".to_string()
21345            } else {
21346                indexed_scopes.join(", ")
21347            };
21348            bail!(
21349                "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: {}.",
21350                root.display(),
21351                root_db.display(),
21352                available_scopes,
21353                indexed_label,
21354            );
21355        }
21356    }
21357
21358    Ok(vec![SearchIndexTarget {
21359        label: "index".to_string(),
21360        db_path: root.join(".tsift/index.db"),
21361        source_root: root.to_path_buf(),
21362        scope_name: None,
21363        reindex_cmd: format!("tsift index {}", root.display()),
21364    }])
21365}
21366
21367fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
21368    if !target.source_root.exists() || !target.db_path.exists() {
21369        return Ok(SearchIndexState::Missing);
21370    }
21371
21372    let inspection =
21373        index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
21374    let stale_files =
21375        inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
21376    if stale_files == 0 {
21377        Ok(SearchIndexState::Fresh)
21378    } else {
21379        Ok(SearchIndexState::Stale { stale_files })
21380    }
21381}
21382
21383#[derive(Debug, Clone, PartialEq, Eq)]
21384struct RebuildSearchTarget {
21385    label: String,
21386    reason: RebuildSearchReason,
21387    reindex_cmd: String,
21388}
21389
21390#[derive(Debug, Clone, PartialEq, Eq)]
21391enum RebuildSearchReason {
21392    Missing,
21393    Stale { stale_files: usize },
21394}
21395
21396#[derive(Debug, Clone, PartialEq, Eq)]
21397struct DegradedSearchTarget {
21398    label: String,
21399    reason: RebuildSearchReason,
21400    reindex_cmd: String,
21401}
21402
21403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21404pub(crate) enum DegradedSearchMode {
21405    ReadOnly,
21406    Exact,
21407}
21408
21409#[derive(Debug)]
21410struct SearchPrecheck {
21411    targets: Vec<SearchIndexTarget>,
21412    degraded_targets: Vec<DegradedSearchTarget>,
21413}
21414
21415fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
21416    err.chain().any(|cause| {
21417        cause
21418            .to_string()
21419            .contains("another tsift index writer is already active")
21420    })
21421}
21422
21423fn infer_agent_doc_task_submodule(
21424    root: &Path,
21425    path_hint: &Path,
21426) -> Result<Option<config::WorkspaceScope>> {
21427    let hinted_path = if path_hint.is_absolute() {
21428        path_hint.to_path_buf()
21429    } else {
21430        root.join(path_hint)
21431    };
21432    let Ok(relative) = hinted_path.strip_prefix(root) else {
21433        return Ok(None);
21434    };
21435    let mut components = relative.components();
21436    let Some(std::path::Component::Normal(first)) = components.next() else {
21437        return Ok(None);
21438    };
21439    if first != "tasks" {
21440        return Ok(None);
21441    }
21442    let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
21443        return Ok(None);
21444    };
21445    config::Config::find_submodule(root, file_stem)
21446}
21447
21448fn degraded_search_target(
21449    target: &SearchIndexTarget,
21450    reason: RebuildSearchReason,
21451) -> DegradedSearchTarget {
21452    DegradedSearchTarget {
21453        label: target.label.clone(),
21454        reason,
21455        reindex_cmd: target.reindex_cmd.clone(),
21456    }
21457}
21458
21459fn apply_search_index_update(
21460    root: &Path,
21461    target: &SearchIndexTarget,
21462) -> Result<index::IndexSummary> {
21463    run_index_update(
21464        &target.db_path,
21465        &target.source_root,
21466        format!("autoindexing {}", target.label),
21467        root,
21468        target.scope_name.as_deref(),
21469        false,
21470        false,
21471    )
21472}
21473
21474fn collect_rebuild_search_targets(
21475    targets: &[SearchIndexTarget],
21476) -> Result<Vec<RebuildSearchTarget>> {
21477    let mut rebuild_targets = Vec::new();
21478    for target in targets {
21479        let reason = match inspect_search_index(target)? {
21480            SearchIndexState::Missing => RebuildSearchReason::Missing,
21481            SearchIndexState::Fresh => continue,
21482            SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
21483        };
21484        rebuild_targets.push(RebuildSearchTarget {
21485            label: target.label.clone(),
21486            reason,
21487            reindex_cmd: target.reindex_cmd.clone(),
21488        });
21489    }
21490    Ok(rebuild_targets)
21491}
21492
21493fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
21494    match target.reason {
21495        RebuildSearchReason::Missing => format!("{} is missing", target.label),
21496        RebuildSearchReason::Stale { stale_files } => {
21497            let file_suffix = if stale_files == 1 { "" } else { "s" };
21498            format!(
21499                "{} is stale ({} file{})",
21500                target.label, stale_files, file_suffix
21501            )
21502        }
21503    }
21504}
21505
21506fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
21507    if rebuild_targets.len() == 1 {
21508        let target = &rebuild_targets[0];
21509        return format!(
21510            "{}. Run `{}` to rebuild before retrying.",
21511            rebuild_search_target_detail(target),
21512            target.reindex_cmd
21513        );
21514    }
21515
21516    let summary: Vec<String> = rebuild_targets
21517        .iter()
21518        .take(3)
21519        .map(rebuild_search_target_detail)
21520        .collect();
21521    let overflow = rebuild_targets.len().saturating_sub(summary.len());
21522    let mut details = summary.join(", ");
21523    if overflow > 0 {
21524        details.push_str(&format!(", +{} more", overflow));
21525    }
21526    let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
21527    format!(
21528        "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
21529        rebuild_targets.len(),
21530        details,
21531        reindex_cmd
21532    )
21533}
21534
21535pub(crate) fn precheck_search_indexes(
21536    root: &Path,
21537    path_hint: &Path,
21538    scope: Option<&str>,
21539    federated: bool,
21540    autoindex: bool,
21541) -> Result<SearchPrecheck> {
21542    let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
21543    let mut stale_targets = Vec::new();
21544    let mut degraded_targets = Vec::new();
21545
21546    for target in &targets {
21547        match inspect_search_index(target)? {
21548            SearchIndexState::Missing => {
21549                if autoindex && let Err(err) = apply_search_index_update(root, target) {
21550                    if is_active_writer_lock_error(&err) {
21551                        degraded_targets
21552                            .push(degraded_search_target(target, RebuildSearchReason::Missing));
21553                    } else {
21554                        return Err(err);
21555                    }
21556                }
21557            }
21558            SearchIndexState::Fresh => {}
21559            SearchIndexState::Stale { stale_files } => {
21560                if autoindex {
21561                    if let Err(err) = apply_search_index_update(root, target) {
21562                        if is_active_writer_lock_error(&err) {
21563                            degraded_targets.push(degraded_search_target(
21564                                target,
21565                                RebuildSearchReason::Stale { stale_files },
21566                            ));
21567                        } else {
21568                            return Err(err);
21569                        }
21570                    }
21571                } else {
21572                    stale_targets.push(RebuildSearchTarget {
21573                        label: target.label.clone(),
21574                        reason: RebuildSearchReason::Stale { stale_files },
21575                        reindex_cmd: target.reindex_cmd.clone(),
21576                    });
21577                }
21578            }
21579        }
21580    }
21581
21582    if stale_targets.is_empty() {
21583        return Ok(SearchPrecheck {
21584            targets,
21585            degraded_targets,
21586        });
21587    }
21588
21589    bail!(
21590        "tsift search aborted: {} \
21591         or re-run without `--no-autoindex`.",
21592        rebuild_search_targets_message(&stale_targets),
21593    );
21594}
21595
21596pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21597    if targets.is_empty() {
21598        return None;
21599    }
21600
21601    if targets
21602        .iter()
21603        .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21604    {
21605        Some(DegradedSearchMode::Exact)
21606    } else {
21607        Some(DegradedSearchMode::ReadOnly)
21608    }
21609}
21610
21611fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21612    if targets.len() == 1 {
21613        let target = &targets[0];
21614        return match target.reason {
21615            RebuildSearchReason::Missing => format!("{} is missing", target.label),
21616            RebuildSearchReason::Stale { stale_files } => {
21617                let file_suffix = if stale_files == 1 { "" } else { "s" };
21618                format!(
21619                    "{} is stale ({} file{})",
21620                    target.label, stale_files, file_suffix
21621                )
21622            }
21623        };
21624    }
21625
21626    let missing = targets
21627        .iter()
21628        .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21629        .count();
21630    let stale = targets.len().saturating_sub(missing);
21631    let mut parts = Vec::new();
21632    if stale > 0 {
21633        let suffix = if stale == 1 { "" } else { "es" };
21634        parts.push(format!("{stale} stale index{suffix}"));
21635    }
21636    if missing > 0 {
21637        let suffix = if missing == 1 { "" } else { "es" };
21638        parts.push(format!("{missing} missing index{suffix}"));
21639    }
21640    parts.join(", ")
21641}
21642
21643pub(crate) fn emit_degraded_search_note(
21644    targets: &[DegradedSearchTarget],
21645    mode: DegradedSearchMode,
21646) {
21647    let summary = degraded_search_targets_summary(targets);
21648    let reindex_cmd = &targets[0].reindex_cmd;
21649    match mode {
21650        DegradedSearchMode::ReadOnly => eprintln!(
21651            "note: active tsift writer detected; skipping autoindex because {}. \
21652             Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21653             Retry `{}` after the active writer finishes for fresh index results.",
21654            summary, reindex_cmd
21655        ),
21656        DegradedSearchMode::Exact => eprintln!(
21657            "note: active tsift writer detected; skipping autoindex because {}. \
21658             Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21659             for indexed symbol hits.",
21660            summary, reindex_cmd
21661        ),
21662    }
21663}
21664
21665fn search_timeout_message(
21666    timeout_secs: u64,
21667    strategy: &str,
21668    targets: &[SearchIndexTarget],
21669) -> Result<String> {
21670    let rebuild_targets = collect_rebuild_search_targets(targets)?;
21671    if rebuild_targets.is_empty() {
21672        return Ok(format!(
21673            "tsift search timed out after {}s (strategy: {}). \
21674             The search root looks fresh, so reindexing is unlikely to help. \
21675             Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21676             or try a different strategy.",
21677            timeout_secs, strategy,
21678        ));
21679    }
21680
21681    Ok(format!(
21682        "tsift search timed out after {}s (strategy: {}). {}",
21683        timeout_secs,
21684        strategy,
21685        rebuild_search_targets_message(&rebuild_targets),
21686    ))
21687}
21688
21689fn is_exact_preferring_query_char(ch: char) -> bool {
21690    matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21691}
21692
21693fn query_prefers_exact_search(query: &str) -> bool {
21694    let trimmed = query.trim();
21695    !trimmed.is_empty()
21696        && !trimmed.chars().any(char::is_whitespace)
21697        && trimmed.chars().any(|ch| ch.is_alphanumeric())
21698        && trimmed.chars().any(is_exact_preferring_query_char)
21699        && trimmed
21700            .chars()
21701            .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21702}
21703
21704pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21705    strategy.unwrap_or_else(|| {
21706        if query_prefers_exact_search(query) {
21707            "exact".to_string()
21708        } else {
21709            "lexical".to_string()
21710        }
21711    })
21712}
21713
21714pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21715    let mut files = Vec::new();
21716    if path.is_file() {
21717        files.push(path.to_path_buf());
21718        return Ok(files);
21719    }
21720    let walker = ignore::WalkBuilder::new(path)
21721        .hidden(true)
21722        .git_ignore(true)
21723        .build();
21724    for entry in walker {
21725        let entry = entry?;
21726        if entry.file_type().is_some_and(|ft| ft.is_file()) {
21727            let p = entry.path();
21728            if let Some(ext) = p.extension() {
21729                let ext = ext.to_string_lossy();
21730                if matches!(
21731                    ext.as_ref(),
21732                    "rs" | "py"
21733                        | "ts"
21734                        | "tsx"
21735                        | "js"
21736                        | "jsx"
21737                        | "kt"
21738                        | "kts"
21739                        | "zig"
21740                        | "sh"
21741                        | "bash"
21742                        | "zsh"
21743                ) {
21744                    files.push(p.to_path_buf());
21745                }
21746            }
21747        }
21748    }
21749    Ok(files)
21750}
21751
21752#[cfg(test)]
21753mod tests {
21754    use super::semantic_edit::{
21755        EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21756        markdown_section_spans,
21757    };
21758    use super::*;
21759    use tsift_memory::{MemoryEventKind, MemoryStore};
21760
21761    use std::cell::RefCell;
21762    use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21763
21764    #[test]
21765    fn graph_db_write_lock_serializes_concurrent_writers() {
21766        let dir = tempfile::tempdir().unwrap();
21767        let graph_db = dir.path().join(".tsift/graph.db");
21768        let short = Duration::from_millis(150);
21769
21770        let first = acquire_graph_db_write_lock_with_timeout(&graph_db, short)
21771            .expect("first writer acquires the lock");
21772        // A second acquire fails (bounded) while the first guard is held — this is
21773        // the cross-process mutual exclusion that protects refresh/snapshot-import.
21774        let second = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21775        assert!(
21776            second.is_err(),
21777            "a second writer must not acquire the graph-db write lock while it is held"
21778        );
21779        drop(first);
21780        // After release the lock is re-acquirable.
21781        let third = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21782        assert!(
21783            third.is_ok(),
21784            "graph-db write lock must be re-acquirable after release"
21785        );
21786    }
21787
21788    // #gdblockcover: `graph-db compact --apply` (DELETE + wal_checkpoint(TRUNCATE)
21789    // + VACUUM) must take the same advisory write lock as refresh/snapshot-import,
21790    // or its VACUUM races a concurrent refresh's WAL transaction. Hold the lock
21791    // externally and prove the compact blocks on it (rather than running unguarded
21792    // as it did before the fix), then completes once the lock is released.
21793    #[test]
21794    fn graph_db_compact_apply_blocks_on_held_write_lock() {
21795        let dir = setup_traversal_project();
21796        let session = dir.path().join("tasks/software/tsift.md");
21797        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
21798        let graph_db = graph_substrate_db_path(dir.path(), None);
21799
21800        let held = acquire_graph_db_write_lock(&graph_db).expect("hold writer lock");
21801
21802        let root = dir.path().to_path_buf();
21803        let handle = std::thread::Builder::new()
21804            .name("compact-apply".to_string())
21805            .stack_size(16 * 1024 * 1024)
21806            .spawn(move || {
21807                crate::commands::infra::cmd_graph_db_compact(
21808                    &root,
21809                    None,
21810                    true,
21811                    false,
21812                    false,
21813                    OutputFormat {
21814                        json_output: true,
21815                        compact: true,
21816                        pretty: false,
21817                        terse: false,
21818                        ultra_terse: false,
21819                        schema: false,
21820                        envelope: false,
21821                    },
21822                )
21823            })
21824            .unwrap();
21825
21826        // While the lock is held the compact cannot have finished — it must be
21827        // parked in the acquire loop. Before the fix it ran VACUUM unguarded and
21828        // would already be done here.
21829        std::thread::sleep(Duration::from_millis(300));
21830        assert!(
21831            !handle.is_finished(),
21832            "compact --apply must block on the held graph-db write lock, not run unguarded"
21833        );
21834
21835        drop(held);
21836        let result = handle.join().expect("compact thread joins");
21837        assert!(
21838            result.is_ok(),
21839            "compact --apply must succeed after the lock is released: {result:?}"
21840        );
21841    }
21842
21843    fn parse_cli<I, T>(itr: I) -> Cli
21844    where
21845        I: IntoIterator<Item = T> + Send + 'static,
21846        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21847    {
21848        std::thread::Builder::new()
21849            .name("cli-parse".to_string())
21850            .stack_size(16 * 1024 * 1024)
21851            .spawn(move || Cli::parse_from(itr))
21852            .unwrap()
21853            .join()
21854            .unwrap()
21855    }
21856
21857    fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21858    where
21859        I: IntoIterator<Item = T> + Send + 'static,
21860        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21861    {
21862        std::thread::Builder::new()
21863            .name("cli-try-parse".to_string())
21864            .stack_size(16 * 1024 * 1024)
21865            .spawn(move || Cli::try_parse_from(itr))
21866            .unwrap()
21867            .join()
21868            .unwrap()
21869    }
21870
21871    fn build_relative_search_budget_report(
21872        query: &str,
21873        strategy: &str,
21874        root: &Path,
21875        response: &sift::SearchResponse,
21876        symbol_hits: &[index::SymbolHit],
21877        budget: ResponseBudget,
21878        filters: &SearchFacetFilters,
21879    ) -> SearchBudgetReport {
21880        build_search_budget_report(SearchBudgetReportInput {
21881            query,
21882            strategy,
21883            root,
21884            response,
21885            symbol_hits,
21886            absolute: false,
21887            budget,
21888            filters,
21889        })
21890    }
21891
21892    #[derive(Default)]
21893    struct MemoryConvexGraphClient {
21894        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21895        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21896    }
21897
21898    impl ConvexGraphClient for MemoryConvexGraphClient {
21899        fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21900            self.nodes
21901                .borrow_mut()
21902                .insert(row.external_id.clone(), row.clone());
21903            Ok(())
21904        }
21905
21906        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21907            self.edges
21908                .borrow_mut()
21909                .insert(row.edge_key.clone(), row.clone());
21910            Ok(())
21911        }
21912
21913        fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21914            Ok(usize::from(
21915                self.nodes.borrow_mut().remove(external_id).is_some(),
21916            ))
21917        }
21918
21919        fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21920            Ok(usize::from(
21921                self.edges.borrow_mut().remove(edge_key).is_some(),
21922            ))
21923        }
21924
21925        fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21926            Ok(self.nodes.borrow().get(external_id).cloned())
21927        }
21928
21929        fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21930            Ok(self.nodes.borrow().values().cloned().collect())
21931        }
21932
21933        fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21934            Ok(self.edges.borrow().values().cloned().collect())
21935        }
21936
21937        fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21938            Ok(self
21939                .nodes
21940                .borrow()
21941                .values()
21942                .filter(|row| row.kind == kind)
21943                .cloned()
21944                .collect())
21945        }
21946
21947        fn outgoing_edge_rows(
21948            &self,
21949            from_external_id: &str,
21950            kind: Option<&str>,
21951        ) -> Result<Vec<ConvexEdgeRow>> {
21952            Ok(self
21953                .edges
21954                .borrow()
21955                .values()
21956                .filter(|row| row.from_external_id == from_external_id)
21957                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21958                .cloned()
21959                .collect())
21960        }
21961    }
21962
21963    fn init_git_repo(path: &Path) {
21964        let status = std::process::Command::new("git")
21965            .args(["init"])
21966            .current_dir(path)
21967            .status()
21968            .unwrap();
21969        assert!(status.success(), "git init failed");
21970
21971        let status = std::process::Command::new("git")
21972            .args(["add", "."])
21973            .current_dir(path)
21974            .status()
21975            .unwrap();
21976        assert!(status.success(), "git add failed");
21977
21978        let status = std::process::Command::new("git")
21979            .args([
21980                "-c",
21981                "user.name=tsift-tests",
21982                "-c",
21983                "user.email=tsift-tests@example.com",
21984                "commit",
21985                "--quiet",
21986                "-m",
21987                "init",
21988            ])
21989            .current_dir(path)
21990            .status()
21991            .unwrap();
21992        assert!(status.success(), "git commit failed");
21993    }
21994
21995    fn write_empty_root_index(root: &Path) {
21996        let index_dir = root.join(".tsift");
21997        fs::create_dir_all(&index_dir).unwrap();
21998        fs::write(index_dir.join("index.db"), "").unwrap();
21999    }
22000
22001    fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
22002        if let Some(parent) = path.parent() {
22003            fs::create_dir_all(parent).unwrap();
22004        }
22005        let body = std::iter::repeat_n(line, lines)
22006            .collect::<Vec<_>>()
22007            .join("\n");
22008        fs::write(path, format!("{body}\n")).unwrap();
22009        path.to_path_buf()
22010    }
22011
22012    // --- build_token_capped_preview ---
22013
22014    #[test]
22015    fn token_capped_preview_returns_all_lines_when_under_cap() {
22016        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 1", "}"];
22017        let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
22018        assert!(!result.was_capped);
22019        assert_eq!(result.preview.len(), 3);
22020        assert_eq!(result.capped_end, 3);
22021    }
22022
22023    #[test]
22024    fn token_capped_preview_truncates_when_over_cap() {
22025        let lines: Vec<&str> = (0..200)
22026            .map(|_| "    let x = some_very_long_expression_here();")
22027            .collect();
22028        let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
22029        assert!(result.was_capped);
22030        assert!(result.preview.len() < 200);
22031        assert!(result.capped_end < 200);
22032    }
22033
22034    #[test]
22035    fn token_capped_preview_keeps_at_least_one_line() {
22036        let long_line: String = "x".repeat(8000);
22037        let lines: Vec<&str> = vec![&long_line];
22038        let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
22039        assert!(!result.was_capped);
22040        assert_eq!(result.preview.len(), 1);
22041    }
22042
22043    #[test]
22044    fn token_capped_preview_cap_at_boundary() {
22045        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22046        let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
22047        assert!(!result.was_capped);
22048        assert_eq!(result.preview.len(), 4);
22049    }
22050
22051    #[test]
22052    fn token_capped_preview_cap_just_over_boundary() {
22053        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22054        let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
22055        assert!(result.was_capped);
22056        assert_eq!(result.preview.len(), 3);
22057        assert_eq!(result.capped_end, 3);
22058    }
22059
22060    #[test]
22061    fn token_capped_preview_empty_lines() {
22062        let lines: Vec<&str> = vec![];
22063        let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
22064        assert!(!result.was_capped);
22065        assert!(result.preview.is_empty());
22066    }
22067
22068    #[test]
22069    fn token_capped_preview_per_line_truncation_applied() {
22070        let long_line = "x".repeat(500);
22071        let lines: Vec<&str> = vec![&long_line, "short"];
22072        let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
22073        assert!(!result.was_capped);
22074        assert_eq!(result.preview.len(), 2);
22075        assert!(result.preview[0].text.len() <= 23);
22076        assert!(result.preview[0].text.ends_with("..."));
22077    }
22078
22079    // --- classify_task ---
22080
22081    #[test]
22082    fn route_search_defaults_to_haiku() {
22083        let (tier, model) = classify_task("find all uses of authenticate");
22084        assert_eq!(tier, "haiku");
22085        assert!(
22086            model.contains("haiku"),
22087            "expected haiku model, got {}",
22088            model
22089        );
22090    }
22091
22092    #[test]
22093    fn route_edit_keywords_to_sonnet() {
22094        for kw in &[
22095            "edit the file",
22096            "fix the bug",
22097            "update the config",
22098            "remove dead code",
22099            "create a new module",
22100        ] {
22101            let (tier, _) = classify_task(kw);
22102            assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
22103        }
22104    }
22105
22106    #[test]
22107    fn route_architecture_keywords_to_opus() {
22108        for kw in &[
22109            "design the API",
22110            "architecture review",
22111            "plan the migration",
22112            "analyze the system",
22113            "evaluate trade-offs",
22114        ] {
22115            let (tier, _) = classify_task(kw);
22116            assert_eq!(tier, "opus", "expected opus for {:?}", kw);
22117        }
22118    }
22119
22120    #[test]
22121    fn route_architecture_beats_edit() {
22122        // "design and implement" — architecture signal wins (checked first)
22123        let (tier, _) = classify_task("design and implement the new auth service");
22124        assert_eq!(tier, "opus");
22125    }
22126
22127    #[test]
22128    fn cli_accepts_global_compact_flag() {
22129        let cli = parse_cli(["tsift", "--compact", "status"]);
22130        assert!(cli.compact);
22131        assert!(matches!(cli.command, Some(Commands::Status { .. })));
22132    }
22133
22134    #[test]
22135    fn summarize_diff_scope_matches_relative_directory() {
22136        let root = Path::new("/repo");
22137        let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
22138
22139        assert!(summarize_diff_matches_scope(
22140            Path::new("/repo/src/feature/main.rs"),
22141            &extract_scope
22142        ));
22143        assert!(!summarize_diff_matches_scope(
22144            Path::new("/repo/src/other/main.rs"),
22145            &extract_scope
22146        ));
22147    }
22148
22149    #[test]
22150    fn summarize_diff_scope_matches_relative_file() {
22151        let root = Path::new("/repo");
22152        let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
22153
22154        assert!(summarize_diff_matches_scope(
22155            Path::new("/repo/src/feature/main.rs"),
22156            &extract_scope
22157        ));
22158        assert!(!summarize_diff_matches_scope(
22159            Path::new("/repo/src/feature/lib.rs"),
22160            &extract_scope
22161        ));
22162    }
22163
22164    #[test]
22165    fn summarize_extract_scope_walks_relative_paths_from_root() {
22166        let dir = tempfile::tempdir().unwrap();
22167        let source_dir = dir.path().join("src");
22168        std::fs::create_dir_all(&source_dir).unwrap();
22169        let main_rs = source_dir.join("main.rs");
22170        std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
22171
22172        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
22173        let files = collect_source_files(&extract_scope).unwrap();
22174
22175        assert_eq!(files, vec![main_rs]);
22176    }
22177
22178    #[test]
22179    fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
22180        let dir = tempfile::tempdir().unwrap();
22181        let nested = dir.path().join("src/nested");
22182        std::fs::create_dir_all(&nested).unwrap();
22183        std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
22184        let nested_file = nested.join("main.rs");
22185        std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
22186
22187        let extract_base = resolve_extract_base(&nested).unwrap();
22188        let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
22189        let files = collect_source_files(&extract_scope).unwrap();
22190
22191        assert_eq!(extract_scope, nested);
22192        assert_eq!(files, vec![nested_file]);
22193    }
22194
22195    #[test]
22196    fn summarize_extract_base_uses_parent_of_file_path() {
22197        let dir = tempfile::tempdir().unwrap();
22198        let nested = dir.path().join("src/nested");
22199        std::fs::create_dir_all(&nested).unwrap();
22200        let file_path = nested.join("main.rs");
22201        std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
22202
22203        let extract_base = resolve_extract_base(&file_path).unwrap();
22204
22205        assert_eq!(extract_base, nested);
22206    }
22207
22208    #[test]
22209    fn summarize_extract_scope_normalizes_dotdot_segments() {
22210        let dir = tempfile::tempdir().unwrap();
22211        let source_dir = dir.path().join("src");
22212        std::fs::create_dir_all(&source_dir).unwrap();
22213
22214        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
22215
22216        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22217        assert!(summarize_diff_matches_scope(
22218            &source_dir.join("main.rs"),
22219            &extract_scope
22220        ));
22221    }
22222
22223    #[cfg(unix)]
22224    #[test]
22225    fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
22226        use std::os::unix::fs::symlink;
22227
22228        let dir = tempfile::tempdir().unwrap();
22229        let real_root = dir.path().join("real");
22230        let source_dir = real_root.join("src");
22231        std::fs::create_dir_all(&source_dir).unwrap();
22232        let symlink_scope = dir.path().join("scope-link");
22233        symlink(&source_dir, &symlink_scope).unwrap();
22234
22235        let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
22236
22237        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22238        assert!(summarize_diff_matches_scope(
22239            &source_dir.join("lib.rs"),
22240            &extract_scope
22241        ));
22242    }
22243
22244    #[test]
22245    fn summarize_diff_extract_includes_untracked_files() {
22246        let dir = tempfile::tempdir().unwrap();
22247        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22248        init_git_repo(dir.path());
22249
22250        let source_dir = dir.path().join("src");
22251        std::fs::create_dir_all(&source_dir).unwrap();
22252        let new_file = source_dir.join("new.rs");
22253        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22254
22255        let files = summarize::git_changed_files(dir.path()).unwrap();
22256
22257        assert_eq!(files.existing, vec![new_file]);
22258        assert!(files.deleted.is_empty());
22259    }
22260
22261    #[test]
22262    fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
22263        let dir = tempfile::tempdir().unwrap();
22264        let status = std::process::Command::new("git")
22265            .args(["init"])
22266            .current_dir(dir.path())
22267            .status()
22268            .unwrap();
22269        assert!(status.success(), "git init failed");
22270
22271        let source_dir = dir.path().join("src");
22272        std::fs::create_dir_all(&source_dir).unwrap();
22273        let new_file = source_dir.join("new.rs");
22274        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22275
22276        let files = summarize::git_changed_files(dir.path()).unwrap();
22277
22278        assert_eq!(files.existing, vec![new_file]);
22279        assert!(files.deleted.is_empty());
22280    }
22281
22282    #[test]
22283    fn summarize_diff_extract_tracks_deleted_files() {
22284        let dir = tempfile::tempdir().unwrap();
22285        let source_dir = dir.path().join("src");
22286        std::fs::create_dir_all(&source_dir).unwrap();
22287        let deleted_file = source_dir.join("gone.rs");
22288        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22289        init_git_repo(dir.path());
22290
22291        std::fs::remove_file(&deleted_file).unwrap();
22292
22293        let files = summarize::git_changed_files(dir.path()).unwrap();
22294
22295        assert!(files.existing.is_empty());
22296        assert_eq!(files.deleted, vec![deleted_file]);
22297    }
22298
22299    #[test]
22300    fn summarize_diff_extract_tracks_git_renames() {
22301        let dir = tempfile::tempdir().unwrap();
22302        let source_dir = dir.path().join("src");
22303        std::fs::create_dir_all(&source_dir).unwrap();
22304        let old_file = source_dir.join("old.rs");
22305        let new_file = source_dir.join("new.rs");
22306        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22307        init_git_repo(dir.path());
22308
22309        let status = std::process::Command::new("git")
22310            .args(["mv", "src/old.rs", "src/new.rs"])
22311            .current_dir(dir.path())
22312            .status()
22313            .unwrap();
22314        assert!(status.success(), "git mv failed");
22315
22316        let files = summarize::git_changed_files(dir.path()).unwrap();
22317
22318        assert_eq!(files.existing, vec![new_file]);
22319        assert_eq!(files.deleted, vec![old_file]);
22320    }
22321
22322    #[test]
22323    fn summarize_diff_extract_deletes_removed_summary_rows() {
22324        let dir = tempfile::tempdir().unwrap();
22325        let source_dir = dir.path().join("src");
22326        std::fs::create_dir_all(&source_dir).unwrap();
22327        let deleted_file = source_dir.join("gone.rs");
22328        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22329        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22330        init_git_repo(dir.path());
22331
22332        let summary_db =
22333            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22334        summary_db
22335            .insert(&summarize::Summary {
22336                id: 0,
22337                symbol_name: "stale".to_string(),
22338                file_path: "src/gone.rs".to_string(),
22339                content_hash: "hash1".to_string(),
22340                summary: "stale summary".to_string(),
22341                entities: None,
22342                relationships: None,
22343                concept_labels: None,
22344                extracted_at: "1700000000".to_string(),
22345                model: "test".to_string(),
22346                tokens_input: Some(100),
22347                tokens_output: Some(50),
22348            })
22349            .unwrap();
22350
22351        std::fs::remove_file(&deleted_file).unwrap();
22352
22353        cmd_summarize(
22354            None,
22355            None,
22356            Some(PathBuf::from("src")),
22357            true,
22358            false,
22359            dir.path(),
22360            false,
22361            true,
22362            false,
22363            false,
22364            false,
22365            None,
22366        )
22367        .unwrap();
22368
22369        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22370    }
22371
22372    #[test]
22373    fn summarize_diff_extract_deletes_renamed_summary_rows() {
22374        let dir = tempfile::tempdir().unwrap();
22375        let source_dir = dir.path().join("src");
22376        std::fs::create_dir_all(&source_dir).unwrap();
22377        let old_file = source_dir.join("old.rs");
22378        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22379        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22380        init_git_repo(dir.path());
22381
22382        let summary_db =
22383            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22384        summary_db
22385            .insert(&summarize::Summary {
22386                id: 0,
22387                symbol_name: "stale".to_string(),
22388                file_path: "src/old.rs".to_string(),
22389                content_hash: "hash1".to_string(),
22390                summary: "stale summary".to_string(),
22391                entities: None,
22392                relationships: None,
22393                concept_labels: None,
22394                extracted_at: "1700000000".to_string(),
22395                model: "test".to_string(),
22396                tokens_input: Some(100),
22397                tokens_output: Some(50),
22398            })
22399            .unwrap();
22400
22401        let status = std::process::Command::new("git")
22402            .args(["mv", "src/old.rs", "src/new.rs"])
22403            .current_dir(dir.path())
22404            .status()
22405            .unwrap();
22406        assert!(status.success(), "git mv failed");
22407
22408        cmd_summarize(
22409            None,
22410            None,
22411            Some(PathBuf::from("src")),
22412            true,
22413            false,
22414            dir.path(),
22415            false,
22416            true,
22417            false,
22418            false,
22419            false,
22420            None,
22421        )
22422        .unwrap();
22423
22424        assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
22425    }
22426
22427    #[test]
22428    fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
22429        let dir = tempfile::tempdir().unwrap();
22430        let source_dir = dir.path().join("src");
22431        std::fs::create_dir_all(&source_dir).unwrap();
22432        let deleted_file = source_dir.join("gone.rs");
22433        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22434
22435        let summary_db =
22436            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22437        summary_db
22438            .insert(&summarize::Summary {
22439                id: 0,
22440                symbol_name: "stale".to_string(),
22441                file_path: "src/gone.rs".to_string(),
22442                content_hash: "hash1".to_string(),
22443                summary: "stale summary".to_string(),
22444                entities: None,
22445                relationships: None,
22446                concept_labels: None,
22447                extracted_at: "1700000000".to_string(),
22448                model: "test".to_string(),
22449                tokens_input: Some(100),
22450                tokens_output: Some(50),
22451            })
22452            .unwrap();
22453
22454        std::fs::remove_file(&deleted_file).unwrap();
22455
22456        cmd_summarize(
22457            None,
22458            None,
22459            Some(PathBuf::from("src")),
22460            false,
22461            false,
22462            dir.path(),
22463            false,
22464            true,
22465            false,
22466            false,
22467            false,
22468            None,
22469        )
22470        .unwrap();
22471
22472        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22473    }
22474
22475    #[test]
22476    fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
22477        let dir = tempfile::tempdir().unwrap();
22478        let source_dir = dir.path().join("src");
22479        std::fs::create_dir_all(&source_dir).unwrap();
22480        let file = source_dir.join("lib.rs");
22481        std::fs::write(&file, "fn helper() {}\n").unwrap();
22482
22483        let content = std::fs::read(&file).unwrap();
22484        let summary_db =
22485            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22486        summary_db
22487            .insert(&summarize::Summary {
22488                id: 0,
22489                symbol_name: "lib.rs".to_string(),
22490                file_path: "src/lib.rs".to_string(),
22491                content_hash: summarize::content_hash(&content),
22492                summary: "cached summary".to_string(),
22493                entities: None,
22494                relationships: None,
22495                concept_labels: None,
22496                extracted_at: "1700000000".to_string(),
22497                model: "test".to_string(),
22498                tokens_input: Some(100),
22499                tokens_output: Some(50),
22500            })
22501            .unwrap();
22502        drop(summary_db);
22503
22504        let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
22505        let _lock = hold_writer_lock(&lock_path);
22506
22507        let err = cmd_summarize(
22508            None,
22509            None,
22510            Some(PathBuf::from("src")),
22511            false,
22512            false,
22513            dir.path(),
22514            false,
22515            true,
22516            false,
22517            false,
22518            false,
22519            None,
22520        )
22521        .unwrap_err();
22522        let message = err.to_string();
22523
22524        assert!(message.contains("another tsift summarize extractor is already active"));
22525        assert!(message.contains("tsift summarize --extract"));
22526    }
22527
22528    #[test]
22529    fn summarize_stats_fails_closed_when_cache_missing() {
22530        let dir = tempfile::tempdir().unwrap();
22531        let err = cmd_summarize(
22532            None,
22533            None,
22534            None,
22535            false,
22536            true,
22537            dir.path(),
22538            false,
22539            false,
22540            false,
22541            false,
22542            false,
22543            None,
22544        )
22545        .unwrap_err();
22546
22547        assert!(
22548            err.to_string().contains("no summaries.db found"),
22549            "got: {err}"
22550        );
22551        assert!(!dir.path().join(".tsift/summaries.db").exists());
22552    }
22553
22554    #[test]
22555    fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22556        let dir = tempfile::tempdir().unwrap();
22557        let summary_db =
22558            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22559        summary_db
22560            .insert(&summarize::Summary {
22561                id: 0,
22562                symbol_name: "alpha_helper".to_string(),
22563                file_path: "src/lib.rs".to_string(),
22564                content_hash: "hash1".to_string(),
22565                summary: "cached summary".to_string(),
22566                entities: None,
22567                relationships: None,
22568                concept_labels: None,
22569                extracted_at: "1700000000".to_string(),
22570                model: "claude-haiku-4-5-20251001".to_string(),
22571                tokens_input: Some(100),
22572                tokens_output: Some(40),
22573            })
22574            .unwrap();
22575        drop(summary_db);
22576        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22577
22578        let result = cmd_summarize(
22579            None,
22580            None,
22581            None,
22582            false,
22583            true,
22584            dir.path(),
22585            false,
22586            false,
22587            false,
22588            false,
22589            false,
22590            None,
22591        );
22592
22593        assert!(result.is_ok());
22594    }
22595
22596    #[test]
22597    fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22598        let dir = tempfile::tempdir().unwrap();
22599        let summary_db =
22600            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22601        summary_db
22602            .insert(&summarize::Summary {
22603                id: 0,
22604                symbol_name: "alpha_helper".to_string(),
22605                file_path: "src/lib.rs".to_string(),
22606                content_hash: "hash1".to_string(),
22607                summary: "cached summary".to_string(),
22608                entities: None,
22609                relationships: None,
22610                concept_labels: None,
22611                extracted_at: "1700000000".to_string(),
22612                model: "claude-haiku-4-5-20251001".to_string(),
22613                tokens_input: Some(100),
22614                tokens_output: Some(40),
22615            })
22616            .unwrap();
22617        drop(summary_db);
22618        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22619
22620        let result = cmd_summarize(
22621            Some("alpha_helper".to_string()),
22622            None,
22623            None,
22624            false,
22625            false,
22626            dir.path(),
22627            false,
22628            true,
22629            false,
22630            false,
22631            false,
22632            None,
22633        );
22634
22635        assert!(result.is_ok());
22636    }
22637
22638    #[test]
22639    fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
22640        let dir = tempfile::tempdir().unwrap();
22641        let nested = dir.path().join("src/nested");
22642        std::fs::create_dir_all(&nested).unwrap();
22643
22644        let summary_db =
22645            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22646        summary_db
22647            .insert(&summarize::Summary {
22648                id: 0,
22649                symbol_name: "alpha_helper".to_string(),
22650                file_path: "src/lib.rs".to_string(),
22651                content_hash: "hash1".to_string(),
22652                summary: "cached summary".to_string(),
22653                entities: None,
22654                relationships: None,
22655                concept_labels: None,
22656                extracted_at: "1700000000".to_string(),
22657                model: "claude-haiku-4-5-20251001".to_string(),
22658                tokens_input: Some(100),
22659                tokens_output: Some(40),
22660            })
22661            .unwrap();
22662
22663        let result = cmd_summarize(
22664            Some("alpha_helper".to_string()),
22665            None,
22666            None,
22667            false,
22668            false,
22669            &nested,
22670            false,
22671            true,
22672            false,
22673            false,
22674            false,
22675            None,
22676        );
22677
22678        assert!(result.is_ok());
22679        assert!(!nested.join(".tsift/summaries.db").exists());
22680    }
22681
22682    #[test]
22683    fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22684        let dir = tempfile::tempdir().unwrap();
22685        std::fs::write(
22686            dir.path().join(".gitmodules"),
22687            r#"[submodule "src/alpha"]
22688	path = src/alpha
22689	url = https://example.com/alpha
22690[submodule "src/beta"]
22691	path = src/beta
22692	url = https://example.com/beta
22693"#,
22694        )
22695        .unwrap();
22696
22697        let alpha_root = dir.path().join("src/alpha");
22698        let beta_root = dir.path().join("src/beta");
22699        std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22700        std::fs::create_dir_all(beta_root.join("src")).unwrap();
22701        std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22702        std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22703        std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22704        let beta_file = beta_root.join("src/lib.rs");
22705        std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22706        std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22707        std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22708
22709        let context = find_symbols_db_for_file(dir.path(), &beta_file)
22710            .unwrap()
22711            .expect("expected matching scoped index");
22712
22713        assert_eq!(
22714            context.db_path,
22715            dir.path().join(".tsift/indexes/beta/index.db")
22716        );
22717        assert_eq!(context.source_root, beta_root);
22718    }
22719
22720    // --- apply_edit_op ---
22721
22722    fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22723        EditOp {
22724            file: PathBuf::from("dummy.txt"),
22725            old: old.to_string(),
22726            new: new.to_string(),
22727            replace_all,
22728        }
22729    }
22730
22731    #[test]
22732    fn edit_replaces_single_occurrence() {
22733        let content = "hello world";
22734        let op = make_op("world", "rust", false);
22735        let (result, count) = apply_edit_op(content, &op).unwrap();
22736        assert_eq!(result, "hello rust");
22737        assert_eq!(count, 1);
22738    }
22739
22740    #[test]
22741    fn edit_replace_all_replaces_every_occurrence() {
22742        let content = "foo foo foo";
22743        let op = make_op("foo", "bar", true);
22744        let (result, count) = apply_edit_op(content, &op).unwrap();
22745        assert_eq!(result, "bar bar bar");
22746        assert_eq!(count, 3);
22747    }
22748
22749    #[test]
22750    fn edit_fails_when_old_not_found() {
22751        let content = "hello world";
22752        let op = make_op("missing", "x", false);
22753        assert!(apply_edit_op(content, &op).is_err());
22754    }
22755
22756    #[test]
22757    fn edit_fails_when_ambiguous_without_replace_all() {
22758        let content = "foo foo";
22759        let op = make_op("foo", "bar", false);
22760        let err = apply_edit_op(content, &op).unwrap_err();
22761        assert!(err.to_string().contains("2 times"), "got: {}", err);
22762    }
22763
22764    #[test]
22765    fn edit_fails_when_old_equals_new() {
22766        let content = "hello";
22767        let op = make_op("hello", "hello", false);
22768        assert!(apply_edit_op(content, &op).is_err());
22769    }
22770
22771    #[test]
22772    fn edit_batch_rolls_back_when_later_swap_fails() {
22773        let dir = tempfile::tempdir().unwrap();
22774        let alpha = dir.path().join("alpha.txt");
22775        let beta = dir.path().join("beta.txt");
22776        fs::write(&alpha, "alpha old\n").unwrap();
22777        fs::write(&beta, "beta old\n").unwrap();
22778
22779        let batch = EditBatch {
22780            edits: vec![
22781                EditOp {
22782                    file: alpha.clone(),
22783                    old: "old".to_string(),
22784                    new: "new".to_string(),
22785                    replace_all: false,
22786                },
22787                EditOp {
22788                    file: beta.clone(),
22789                    old: "old".to_string(),
22790                    new: "new".to_string(),
22791                    replace_all: false,
22792                },
22793            ],
22794        };
22795
22796        let plan = build_edit_plan(&batch).unwrap();
22797        let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22798            if commit_index == 1 {
22799                bail!("simulated swap failure");
22800            }
22801            Ok(())
22802        }) {
22803            Ok(_) => panic!("expected simulated swap failure"),
22804            Err(err) => err,
22805        };
22806
22807        assert!(err.to_string().contains("simulated swap failure"));
22808        assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22809        assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22810    }
22811
22812    // --- SQL introspection ---
22813
22814    fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22815        let tmp = tempfile::NamedTempFile::new().unwrap();
22816        let conn = Connection::open(tmp.path()).unwrap();
22817        conn.execute_batch(
22818            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22819             INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22820             INSERT INTO users VALUES (2, 'Bob', NULL);
22821             CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22822                 FOREIGN KEY(user_id) REFERENCES users(id));
22823             INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22824             INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22825             INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22826        ).unwrap();
22827        (tmp, conn)
22828    }
22829
22830    // --- rewrite_command ---
22831
22832    #[test]
22833    fn rewrite_rg_simple_pattern() {
22834        let result = rewrite_command("rg authenticate");
22835        assert_eq!(
22836            result,
22837            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22838        );
22839    }
22840
22841    #[test]
22842    fn rewrite_rg_with_path() {
22843        let result = rewrite_command("rg authenticate src/");
22844        assert_eq!(
22845            result,
22846            Some(
22847                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22848                    .to_string()
22849            )
22850        );
22851    }
22852
22853    #[test]
22854    fn rewrite_rg_with_flags_ignored() {
22855        let result = rewrite_command("rg -i authenticate src/");
22856        assert_eq!(
22857            result,
22858            Some(
22859                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22860                    .to_string()
22861            )
22862        );
22863    }
22864
22865    #[test]
22866    fn rewrite_rg_with_type_flag() {
22867        // -t rs takes a value, should be skipped; pattern is next positional
22868        let result = rewrite_command("rg -t rs authenticate");
22869        assert_eq!(
22870            result,
22871            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
22872        );
22873    }
22874
22875    #[test]
22876    fn rewrite_rg_pipe_passthrough() {
22877        // Pipe chains can't be translated — pass through
22878        let result = rewrite_command("rg authenticate | head -5");
22879        assert_eq!(result, None);
22880    }
22881
22882    #[test]
22883    fn rewrite_rg_files_passthrough() {
22884        let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22885        assert_eq!(result, None);
22886    }
22887
22888    #[test]
22889    fn rewrite_find_passthrough() {
22890        let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22891        assert_eq!(result, None);
22892    }
22893
22894    #[test]
22895    fn rewrite_grep_recursive() {
22896        let result = rewrite_command("grep -r authenticate src/");
22897        assert_eq!(
22898            result,
22899            Some(
22900                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22901                    .to_string()
22902            )
22903        );
22904    }
22905
22906    #[test]
22907    fn rewrite_grep_non_recursive_passthrough() {
22908        let result = rewrite_command("grep authenticate file.txt");
22909        assert_eq!(result, None);
22910    }
22911
22912    #[test]
22913    fn rewrite_tsift_passthrough() {
22914        let result = rewrite_command("tsift search \"foo\"");
22915        assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22916    }
22917
22918    #[test]
22919    fn rewrite_run_tsift_search_disables_timeout_by_default() {
22920        let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22921        assert_eq!(
22922            result,
22923            "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22924        );
22925    }
22926
22927    #[test]
22928    fn rewrite_run_preserves_explicit_search_timeout() {
22929        let result = effective_rewrite_run_command(
22930            "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22931        );
22932        assert_eq!(
22933            result,
22934            "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22935        );
22936    }
22937
22938    #[test]
22939    fn rewrite_unrelated_passthrough() {
22940        let result = rewrite_command("echo cargo build");
22941        assert_eq!(result, None);
22942    }
22943
22944    #[test]
22945    fn rewrite_rg_quoted_pattern() {
22946        let result = rewrite_command("rg \"fn main\"");
22947        assert_eq!(
22948            result,
22949            Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22950        );
22951    }
22952
22953    #[test]
22954    fn rewrite_git_diff_to_diff_digest() {
22955        let result = rewrite_command("git diff");
22956        assert_eq!(result, Some("tsift diff-digest .".to_string()));
22957    }
22958
22959    #[test]
22960    fn rewrite_git_diff_cached_to_diff_digest() {
22961        let result = rewrite_command("git diff --cached");
22962        assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22963    }
22964
22965    #[test]
22966    fn rewrite_git_diff_with_path_to_diff_digest() {
22967        let result = rewrite_command("git diff -- src/");
22968        assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22969    }
22970
22971    #[test]
22972    fn rewrite_git_diff_with_revision_passthrough() {
22973        let result = rewrite_command("git diff HEAD~1");
22974        assert_eq!(result, None);
22975    }
22976
22977    #[test]
22978    fn rewrite_git_show_to_revision_diff_digest() {
22979        let result = rewrite_command("git show HEAD~1");
22980        assert_eq!(
22981            result,
22982            Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
22983        );
22984    }
22985
22986    #[test]
22987    fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22988        let result = rewrite_command("git log -p -1 HEAD~2");
22989        assert_eq!(
22990            result,
22991            Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22992        );
22993    }
22994
22995    #[test]
22996    fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22997        let dir = tempfile::tempdir().unwrap();
22998        let session = dir.path().join("tsift.md");
22999        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23000        for index in 0..90 {
23001            body.push_str(&format!("❯ prompt {index}?\n"));
23002        }
23003        fs::write(&session, body).unwrap();
23004
23005        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23006        assert_eq!(
23007            result,
23008            Some(format!(
23009                "tsift session-digest --path {} --input {} --source markdown",
23010                shell_quote(&resolve_digest_context_path(&session)),
23011                shell_quote(session.to_str().unwrap())
23012            ))
23013        );
23014    }
23015
23016    #[test]
23017    fn rewrite_head_long_claude_jsonl_to_session_digest() {
23018        let dir = tempfile::tempdir().unwrap();
23019        let session = dir.path().join("session.jsonl");
23020        let line =
23021            r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
23022        let body = std::iter::repeat_n(line, 120)
23023            .collect::<Vec<_>>()
23024            .join("\n");
23025        fs::write(&session, format!("{body}\n")).unwrap();
23026
23027        let result = rewrite_command(&format!(
23028            "head -n 120 {}",
23029            shell_quote(session.to_str().unwrap())
23030        ));
23031        assert_eq!(
23032            result,
23033            Some(format!(
23034                "tsift session-digest --path {} --input {} --source claude-jsonl",
23035                shell_quote(&resolve_digest_context_path(&session)),
23036                shell_quote(session.to_str().unwrap())
23037            ))
23038        );
23039    }
23040
23041    #[test]
23042    fn rewrite_head_long_codex_jsonl_to_session_digest() {
23043        let dir = tempfile::tempdir().unwrap();
23044        let session = dir.path().join("codex.jsonl");
23045        let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
23046        let body = std::iter::repeat_n(line, 120)
23047            .collect::<Vec<_>>()
23048            .join("\n");
23049        fs::write(&session, format!("{body}\n")).unwrap();
23050
23051        let result = rewrite_command(&format!(
23052            "head -n 120 {}",
23053            shell_quote(session.to_str().unwrap())
23054        ));
23055        assert_eq!(
23056            result,
23057            Some(format!(
23058                "tsift session-digest --path {} --input {} --source codex-jsonl",
23059                shell_quote(&resolve_digest_context_path(&session)),
23060                shell_quote(session.to_str().unwrap())
23061            ))
23062        );
23063    }
23064
23065    #[test]
23066    fn rewrite_small_transcript_window_passthrough() {
23067        let dir = tempfile::tempdir().unwrap();
23068        let session = dir.path().join("session.jsonl");
23069        let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
23070        let body = std::iter::repeat_n(line, 120)
23071            .collect::<Vec<_>>()
23072            .join("\n");
23073        fs::write(&session, format!("{body}\n")).unwrap();
23074
23075        let result = rewrite_command(&format!(
23076            "tail -n 20 {}",
23077            shell_quote(session.to_str().unwrap())
23078        ));
23079        assert_eq!(result, None);
23080    }
23081
23082    #[test]
23083    fn rewrite_sed_large_agent_doc_range_to_session_digest() {
23084        let dir = tempfile::tempdir().unwrap();
23085        let session = dir.path().join("tsift.md");
23086        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23087        for index in 0..120 {
23088            body.push_str(&format!("### Re: topic {index}\n"));
23089        }
23090        fs::write(&session, body).unwrap();
23091
23092        let result = rewrite_command(&format!(
23093            "sed -n '1,120p' {}",
23094            shell_quote(session.to_str().unwrap())
23095        ));
23096        assert_eq!(
23097            result,
23098            Some(format!(
23099                "tsift session-digest --path {} --input {} --source markdown",
23100                shell_quote(&resolve_digest_context_path(&session)),
23101                shell_quote(session.to_str().unwrap())
23102            ))
23103        );
23104    }
23105
23106    #[test]
23107    fn rewrite_cat_large_agent_doc_log_to_session_digest() {
23108        let dir = tempfile::tempdir().unwrap();
23109        let session = dir.path().join("tsift.log");
23110        let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
23111        let body = std::iter::repeat_n(line, 120)
23112            .collect::<Vec<_>>()
23113            .join("\n");
23114        fs::write(&session, format!("{body}\n")).unwrap();
23115
23116        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23117        assert_eq!(
23118            result,
23119            Some(format!(
23120                "tsift session-digest --path {} --input {} --source agent-doc-log",
23121                shell_quote(&resolve_digest_context_path(&session)),
23122                shell_quote(session.to_str().unwrap())
23123            ))
23124        );
23125    }
23126
23127    #[test]
23128    fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
23129        let dir = tempfile::tempdir().unwrap();
23130        fs::write(
23131            dir.path().join(".gitmodules"),
23132            r#"[submodule "src/tsift"]
23133	path = src/tsift
23134	url = https://example.com/tsift
23135"#,
23136        )
23137        .unwrap();
23138        let submodule = dir.path().join("src/tsift");
23139        fs::create_dir_all(submodule.join("tasks")).unwrap();
23140        fs::write(
23141            submodule.join(".git"),
23142            "gitdir: ../../.git/modules/src/tsift\n",
23143        )
23144        .unwrap();
23145        let session = submodule.join("tasks/plan.md");
23146        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23147        for index in 0..90 {
23148            body.push_str(&format!("❯ prompt {index}?\n"));
23149        }
23150        fs::write(&session, body).unwrap();
23151
23152        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23153
23154        assert_eq!(
23155            result,
23156            Some(format!(
23157                "tsift session-digest --path {} --input {} --source markdown",
23158                shell_quote(submodule.to_str().unwrap()),
23159                shell_quote(session.to_str().unwrap())
23160            ))
23161        );
23162    }
23163
23164    #[test]
23165    fn rewrite_regular_markdown_read_passthrough() {
23166        let dir = tempfile::tempdir().unwrap();
23167        let readme = dir.path().join("README.md");
23168        let body = std::iter::repeat_n("plain markdown", 120)
23169            .collect::<Vec<_>>()
23170            .join("\n");
23171        fs::write(&readme, format!("{body}\n")).unwrap();
23172
23173        let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
23174        assert_eq!(result, None);
23175    }
23176
23177    #[test]
23178    fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
23179        let dir = tempfile::tempdir().unwrap();
23180        write_empty_root_index(dir.path());
23181        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23182
23183        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23184
23185        assert_eq!(
23186            result,
23187            Some(format!(
23188                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
23189                shell_quote(&dir.path().to_string_lossy())
23190            ))
23191        );
23192    }
23193
23194    #[test]
23195    fn rewrite_head_small_source_window_passthrough() {
23196        let dir = tempfile::tempdir().unwrap();
23197        write_empty_root_index(dir.path());
23198        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23199
23200        let result = rewrite_command(&format!(
23201            "head -n 20 {}",
23202            shell_quote(source.to_str().unwrap())
23203        ));
23204
23205        assert_eq!(result, None);
23206    }
23207
23208    #[test]
23209    fn rewrite_sed_large_source_range_to_source_read() {
23210        let dir = tempfile::tempdir().unwrap();
23211        write_empty_root_index(dir.path());
23212        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23213
23214        let result = rewrite_command(&format!(
23215            "sed -n '40,160p' {}",
23216            shell_quote(source.to_str().unwrap())
23217        ));
23218
23219        assert_eq!(
23220            result,
23221            Some(format!(
23222                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
23223                shell_quote(&dir.path().to_string_lossy())
23224            ))
23225        );
23226    }
23227
23228    #[test]
23229    fn rewrite_tail_large_source_window_preserves_tail_anchor() {
23230        let dir = tempfile::tempdir().unwrap();
23231        write_empty_root_index(dir.path());
23232        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23233
23234        let result = rewrite_command(&format!(
23235            "tail -n 120 {}",
23236            shell_quote(source.to_str().unwrap())
23237        ));
23238
23239        assert_eq!(
23240            result,
23241            Some(format!(
23242                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
23243                shell_quote(&dir.path().to_string_lossy())
23244            ))
23245        );
23246    }
23247
23248    #[test]
23249    fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
23250        let dir = tempfile::tempdir().unwrap();
23251        write_empty_root_index(dir.path());
23252        let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
23253
23254        let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
23255
23256        assert_eq!(result, None);
23257    }
23258
23259    #[test]
23260    fn rewrite_large_source_read_passthrough_without_index() {
23261        let dir = tempfile::tempdir().unwrap();
23262        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23263
23264        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23265
23266        assert_eq!(result, None);
23267    }
23268
23269    #[test]
23270    fn rewrite_cargo_test_to_digest_runner() {
23271        let result = rewrite_command("cargo test --lib");
23272        assert_eq!(
23273            result,
23274            Some(
23275                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
23276            )
23277        );
23278    }
23279
23280    #[test]
23281    fn rewrite_pytest_to_digest_runner() {
23282        let result = rewrite_command("pytest -q tests/test_cli.py");
23283        assert_eq!(
23284            result,
23285            Some(
23286                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
23287            )
23288        );
23289    }
23290
23291    #[test]
23292    fn rewrite_python_m_pytest_to_digest_runner() {
23293        let result = rewrite_command("python -m pytest tests/test_cli.py");
23294        assert_eq!(
23295            result,
23296            Some(
23297                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
23298            )
23299        );
23300    }
23301
23302    #[test]
23303    fn rewrite_cargo_build_to_log_digest_runner() {
23304        let result = rewrite_command("cargo build --release");
23305        assert_eq!(
23306            result,
23307            Some(
23308                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
23309            )
23310        );
23311    }
23312
23313    #[test]
23314    fn rewrite_cargo_install_to_log_digest_runner() {
23315        let result = rewrite_command("cargo install --path . --force");
23316        assert_eq!(
23317            result,
23318            Some(
23319                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
23320            )
23321        );
23322    }
23323
23324    #[test]
23325    fn rewrite_metacharacter_command_passthrough() {
23326        let result = rewrite_command("cargo test | head");
23327        assert_eq!(result, None);
23328    }
23329
23330    #[test]
23331    fn rewrite_output_cap_detects_search_even_with_global_flag() {
23332        let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
23333        assert_eq!(cap.max_lines, 50);
23334        assert_eq!(cap.strip_prefix, Some("Strategy:"));
23335    }
23336
23337    #[test]
23338    fn rewrite_output_cap_skips_structured_output() {
23339        assert!(rewrite_output_cap("tsift search foo --json").is_none());
23340        assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
23341        assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
23342    }
23343
23344    #[test]
23345    fn rewrite_output_format_forwards_envelope_to_digest_runner() {
23346        let command = rewrite_command("cargo test --lib").expect("rewrite");
23347        let forwarded = apply_rewrite_output_format(
23348            &command,
23349            OutputFormat {
23350                json_output: true,
23351                compact: false,
23352                pretty: false,
23353                terse: false,
23354                ultra_terse: false,
23355                schema: false,
23356                envelope: true,
23357            },
23358        );
23359        assert_eq!(
23360            forwarded,
23361            "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
23362        );
23363    }
23364
23365    #[test]
23366    fn rewrite_output_format_forwards_json_when_requested() {
23367        let command = rewrite_command("cargo build --release").expect("rewrite");
23368        let forwarded = apply_rewrite_output_format(
23369            &command,
23370            OutputFormat {
23371                json_output: true,
23372                compact: false,
23373                pretty: true,
23374                terse: false,
23375                ultra_terse: false,
23376                schema: false,
23377                envelope: false,
23378            },
23379        );
23380        assert_eq!(
23381            forwarded,
23382            "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
23383        );
23384    }
23385
23386    #[test]
23387    fn output_cap_strips_search_header_and_truncates() {
23388        let capped = apply_output_cap(
23389            b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
23390            OutputCap {
23391                max_lines: 2,
23392                strip_prefix: Some("Strategy:"),
23393            },
23394        );
23395        assert_eq!(
23396            capped,
23397            "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
23398        );
23399    }
23400
23401    #[test]
23402    fn sql_schema_overview_lists_tables() {
23403        let (_tmp, conn) = setup_test_db();
23404        let tables = schema_overview(&conn).unwrap();
23405        let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
23406        assert_eq!(names, &["posts", "users"]);
23407    }
23408
23409    #[test]
23410    fn sql_schema_overview_row_counts() {
23411        let (_tmp, conn) = setup_test_db();
23412        let tables = schema_overview(&conn).unwrap();
23413        let users = tables.iter().find(|t| t.name == "users").unwrap();
23414        let posts = tables.iter().find(|t| t.name == "posts").unwrap();
23415        assert_eq!(users.row_count, 2);
23416        assert_eq!(posts.row_count, 3);
23417    }
23418
23419    #[test]
23420    fn sql_table_columns_metadata() {
23421        let (_tmp, conn) = setup_test_db();
23422        let cols = table_columns(&conn, "users").unwrap();
23423        assert_eq!(cols.len(), 3);
23424        assert_eq!(cols[0].name, "id");
23425        assert!(cols[0].pk);
23426        assert_eq!(cols[1].name, "name");
23427        assert!(cols[1].notnull);
23428        assert_eq!(cols[2].name, "email");
23429        assert!(!cols[2].notnull);
23430    }
23431
23432    #[test]
23433    fn sql_execute_query_returns_rows() {
23434        let (_tmp, conn) = setup_test_db();
23435        let (columns, rows) =
23436            execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
23437        assert_eq!(columns, &["name", "email"]);
23438        assert_eq!(rows.len(), 2);
23439        assert_eq!(rows[0][0], serde_json::json!("Alice"));
23440        assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
23441        assert_eq!(rows[1][1], serde_json::Value::Null);
23442    }
23443
23444    #[test]
23445    fn sql_execute_query_aggregate() {
23446        let (_tmp, conn) = setup_test_db();
23447        let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
23448        assert_eq!(columns, &["cnt"]);
23449        assert_eq!(rows[0][0], serde_json::json!(3));
23450    }
23451
23452    #[test]
23453    fn sql_execute_query_join() {
23454        let (_tmp, conn) = setup_test_db();
23455        let (_cols, rows) = execute_query(
23456            &conn,
23457            "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
23458        )
23459        .unwrap();
23460        assert_eq!(rows.len(), 3);
23461        assert_eq!(rows[0][0], serde_json::json!("Alice"));
23462        assert_eq!(rows[2][0], serde_json::json!("Bob"));
23463    }
23464
23465    #[test]
23466    fn sql_open_db_read_only() {
23467        let (tmp, _conn) = setup_test_db();
23468        drop(_conn);
23469        let ro_conn = open_db(tmp.path()).unwrap();
23470        let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
23471        assert!(result.is_err(), "read-only connection should reject writes");
23472    }
23473
23474    #[test]
23475    fn sql_empty_table_schema() {
23476        let tmp = tempfile::NamedTempFile::new().unwrap();
23477        let conn = Connection::open(tmp.path()).unwrap();
23478        conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
23479            .unwrap();
23480        let tables = schema_overview(&conn).unwrap();
23481        assert_eq!(tables[0].row_count, 0);
23482        assert_eq!(tables[0].columns.len(), 2);
23483    }
23484
23485    // --- graph command ---
23486
23487    fn setup_graph_index() -> tempfile::TempDir {
23488        let dir = tempfile::tempdir().unwrap();
23489        std::fs::write(
23490            dir.path().join("main.rs"),
23491            "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
23492        )
23493        .unwrap();
23494        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23495        db.apply_changes(dir.path()).unwrap();
23496        dir
23497    }
23498
23499    fn setup_traversal_project() -> tempfile::TempDir {
23500        let dir = setup_graph_index();
23501        let task_dir = dir.path().join("tasks/software");
23502        std::fs::create_dir_all(&task_dir).unwrap();
23503        std::fs::write(
23504            task_dir.join("tsift.md"),
23505            r#"---
23506agent_doc_session: tsift-v0.1
23507agent_doc_format: template
23508---
23509
23510## Exchange
23511
23512<!-- agent:exchange patch=append -->
23513❯ do [#kgnv]
23514Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
23515<!-- /agent:exchange -->
23516
23517<!-- agent:queue -->
23518dispatch #spec-test-build-install-commit-push
23519- do [#kgnv]
23520<!-- /agent:queue -->
23521
23522## Backlog
23523
23524<!-- agent:backlog -->
23525- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
23526<!-- /agent:backlog -->
23527"#,
23528        )
23529        .unwrap();
23530        dir
23531    }
23532
23533    fn resolve_ast_span_node<'a>(
23534        graph: &'a TraversalGraphBuild,
23535        label: &str,
23536        symbol_kind: &str,
23537    ) -> &'a TraversalNode {
23538        graph
23539            .nodes
23540            .values()
23541            .find(|node| {
23542                node.kind == "ast_span"
23543                    && node.label == label
23544                    && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
23545            })
23546            .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
23547    }
23548
23549    fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
23550        let dir = tempfile::tempdir().unwrap();
23551        std::fs::write(
23552            dir.path().join("rust.rs"),
23553            r#"mod fixture_nav_rust_mod {
23554    pub fn fixture_nav_rust_helper() {}
23555    pub fn fixture_nav_rust_entry() {
23556        fixture_nav_rust_helper();
23557    }
23558}
23559"#,
23560        )
23561        .unwrap();
23562        std::fs::write(
23563            dir.path().join("python.py"),
23564            r#"def fixture_nav_python_helper():
23565    return 1
23566
23567def fixture_nav_python_entry():
23568    return fixture_nav_python_helper()
23569"#,
23570        )
23571        .unwrap();
23572        std::fs::write(
23573            dir.path().join("typescript.ts"),
23574            r#"export function fixture_nav_typescript_entry(): number {
23575    return fixtureNavTsHelper();
23576}
23577
23578function fixtureNavTsHelper(): number {
23579    return 1;
23580}
23581"#,
23582        )
23583        .unwrap();
23584        std::fs::write(
23585            dir.path().join("javascript.js"),
23586            r#"function fixture_nav_javascript_entry() {
23587    return fixtureNavJsHelper();
23588}
23589
23590function fixtureNavJsHelper() {
23591    return 1;
23592}
23593"#,
23594        )
23595        .unwrap();
23596        std::fs::write(
23597            dir.path().join("kotlin.kt"),
23598            r#"fun fixture_nav_kotlin_entry(): Int {
23599    return fixtureNavKotlinHelper()
23600}
23601
23602fun fixtureNavKotlinHelper(): Int = 1
23603"#,
23604        )
23605        .unwrap();
23606        std::fs::write(
23607            dir.path().join("zig.zig"),
23608            r#"pub fn fixture_nav_zig_entry() i32 {
23609    return fixtureNavZigHelper();
23610}
23611
23612fn fixtureNavZigHelper() i32 {
23613    return 1;
23614}
23615"#,
23616        )
23617        .unwrap();
23618        std::fs::write(
23619            dir.path().join("bash.sh"),
23620            r#"#!/usr/bin/env bash
23621fixture_nav_bash_entry() {
23622    fixture_nav_bash_helper
23623}
23624
23625fixture_nav_bash_helper() {
23626    echo ok
23627}
23628
23629alias fixture_nav_bash_alias='echo alias'
23630"#,
23631        )
23632        .unwrap();
23633        std::fs::write(
23634            dir.path().join("README.md"),
23635            r#"# Fixture Guide
23636
23637## Fixture Section
23638
23639- Fixture step
23640  - Nested fixture step
23641
23642```python
23643def fixture_nav_markdown_embedded():
23644    return 1
23645```
23646"#,
23647        )
23648        .unwrap();
23649
23650        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23651        db.apply_changes(dir.path()).unwrap();
23652        dir
23653    }
23654
23655    fn assert_cli_expand_command_parses(command: &str) {
23656        let args = shell_split(command)
23657            .into_iter()
23658            .map(str::to_string)
23659            .collect::<Vec<_>>();
23660        assert!(
23661            try_parse_cli(args).is_ok(),
23662            "expand command should parse as a tsift CLI command: {command}"
23663        );
23664    }
23665
23666    fn setup_multiplicity_project() -> tempfile::TempDir {
23667        let dir = tempfile::tempdir().unwrap();
23668        std::fs::write(
23669            dir.path().join("Cargo.toml"),
23670            r#"[workspace]
23671members = ["crates/core-lib", "crates/cli-app"]
23672"#,
23673        )
23674        .unwrap();
23675        std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23676        std::fs::write(
23677            dir.path().join("crates/core-lib/Cargo.toml"),
23678            r#"[package]
23679name = "core-lib"
23680
23681[lib]
23682name = "core_lib"
23683
23684[features]
23685default = []
23686"#,
23687        )
23688        .unwrap();
23689        std::fs::write(
23690            dir.path().join("crates/core-lib/src/lib.rs"),
23691            "pub fn run() {}\n",
23692        )
23693        .unwrap();
23694        std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23695        std::fs::write(
23696            dir.path().join("crates/cli-app/Cargo.toml"),
23697            r#"[package]
23698name = "cli-app"
23699
23700[[bin]]
23701name = "cli-app"
23702
23703[dependencies]
23704core-lib = { path = "../core-lib" }
23705"#,
23706        )
23707        .unwrap();
23708        std::fs::write(
23709            dir.path().join("crates/cli-app/src/main.rs"),
23710            "use core_lib::run;\nfn main() { run(); }\n",
23711        )
23712        .unwrap();
23713        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23714        db.apply_changes(dir.path()).unwrap();
23715
23716        let task_dir = dir.path().join("tasks/software");
23717        std::fs::create_dir_all(&task_dir).unwrap();
23718        std::fs::write(
23719            task_dir.join("tsift.md"),
23720            r#"---
23721agent_doc_session: tsift-multiplicity
23722agent_doc_format: template
23723---
23724
23725## Backlog
23726
23727<!-- agent:backlog -->
23728- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23729<!-- /agent:backlog -->
23730"#,
23731        )
23732        .unwrap();
23733        init_git_repo(dir.path());
23734        dir
23735    }
23736
23737    fn setup_dependency_dag_project() -> tempfile::TempDir {
23738        let dir = tempfile::tempdir().unwrap();
23739        std::fs::write(
23740            dir.path().join("main.rs"),
23741            "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23742        )
23743        .unwrap();
23744        std::fs::write(
23745            dir.path().join("Cargo.toml"),
23746            "[package]\nname = \"dag-fixture\"\n",
23747        )
23748        .unwrap();
23749        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23750        db.apply_changes(dir.path()).unwrap();
23751
23752        let task_dir = dir.path().join("tasks/software");
23753        std::fs::create_dir_all(&task_dir).unwrap();
23754        std::fs::write(
23755            task_dir.join("tsift.md"),
23756            r#"---
23757agent_doc_session: tsift-dag
23758agent_doc_format: template
23759---
23760
23761## Exchange
23762
23763<!-- agent:exchange patch=append -->
23764Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23765<!-- /agent:exchange -->
23766
23767## Backlog
23768
23769<!-- agent:backlog -->
23770- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23771- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23772- [ ] [#beta] Refactor shared_helper tests in main.rs.
23773- [ ] [#gamma] Follow-up review for graph navigation.
23774<!-- /agent:backlog -->
23775"#,
23776        )
23777        .unwrap();
23778        dir
23779    }
23780
23781    fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23782        let dir = setup_graph_index();
23783        let task_dir = dir.path().join("tasks/software");
23784        std::fs::create_dir_all(&task_dir).unwrap();
23785        std::fs::write(
23786            task_dir.join("tsift.md"),
23787            r#"---
23788agent_doc_session: tsift-dag-cycle
23789agent_doc_format: template
23790---
23791
23792## Backlog
23793
23794<!-- agent:backlog -->
23795- [ ] [#left] Left side depends on #right.
23796- [ ] [#right] Right side depends on #left.
23797<!-- /agent:backlog -->
23798"#,
23799        )
23800        .unwrap();
23801        dir
23802    }
23803
23804    fn seed_traversal_semantic_summaries(dir: &Path) {
23805        let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23806        summary_db
23807            .insert(&summarize::Summary {
23808                id: 0,
23809                symbol_name: "helper".to_string(),
23810                file_path: "main.rs".to_string(),
23811                content_hash: "hash-main".to_string(),
23812                summary: "helper builds graph navigation handles for traversal.".to_string(),
23813                entities: Some(vec![
23814                    summarize::Entity {
23815                        name: "helper".to_string(),
23816                        kind: "function".to_string(),
23817                        description: "Builds graph navigation handles.".to_string(),
23818                    },
23819                    summarize::Entity {
23820                        name: "TraversalGraph".to_string(),
23821                        kind: "type".to_string(),
23822                        description: "Carries GraphStore-backed traversal rows.".to_string(),
23823                    },
23824                ]),
23825                relationships: Some(vec![summarize::Relationship {
23826                    from: "helper".to_string(),
23827                    to: "TraversalGraph".to_string(),
23828                    kind: "uses".to_string(),
23829                }]),
23830                concept_labels: Some(vec![
23831                    "graph navigation".to_string(),
23832                    "semantic extraction".to_string(),
23833                ]),
23834                extracted_at: "1700000000".to_string(),
23835                model: "test-model".to_string(),
23836                tokens_input: Some(10),
23837                tokens_output: Some(5),
23838            })
23839            .unwrap();
23840    }
23841
23842    fn seed_tsift_memory_graph_db(dir: &Path) {
23843        let db = dir.join(".tsift").join("memory.db");
23844        let store = MemoryStore::open_or_create(&db).unwrap();
23845        let project = dir.to_string_lossy().to_string();
23846        let observation = MemoryEvent::new(
23847            MemoryEventKind::ImportedObservation,
23848            "claude-mem:observations:1",
23849            [
23850                "Graph memory adapter",
23851                "read-only projection",
23852                "graph-db should retrieve tsift memory observations",
23853                "Project memory is queried from .tsift/memory.db",
23854                "graph memory, tsift memory, semantic query",
23855            ]
23856            .join("\n\n"),
23857        )
23858        .with_session_id("claude-session-a")
23859        .with_observed_at_unix(1_700_000_000)
23860        .with_import("claude-mem", "observations:1")
23861        .with_metadata("project", project.clone())
23862        .with_metadata("observation_type", "fact")
23863        .with_metadata("prompt_number", "7")
23864        .with_metadata("discovery_tokens", "42")
23865        .with_metadata("content_hash", "hash-observation-1");
23866        store.insert_event(&observation).unwrap();
23867
23868        let summary = MemoryEvent::new(
23869            MemoryEventKind::ImportedSessionSummary,
23870            "claude-mem:session_summaries:2",
23871            [
23872                "Query old memory from graph-db",
23873                "Read-only tsift memory SQLite projection",
23874                "Semantic graph rows can point at existing memory",
23875                "Projected source and session nodes",
23876                "Keep capture ownership inside tsift-memory",
23877                "summary note",
23878            ]
23879            .join("\n\n"),
23880        )
23881        .with_session_id("claude-session-a")
23882        .with_observed_at_unix(1_700_000_010)
23883        .with_import("claude-mem", "session_summaries:2")
23884        .with_metadata("project", project)
23885        .with_metadata("prompt_number", "8")
23886        .with_metadata("discovery_tokens", "36");
23887        store.insert_event(&summary).unwrap();
23888
23889        let prompt = MemoryEvent::new(
23890            MemoryEventKind::ImportedUserPrompt,
23891            "claude-mem:user_prompts:3",
23892            "How can graph-db query tsift memory semantic history?",
23893        )
23894        .with_session_id("claude-session-a")
23895        .with_observed_at_unix(1_700_000_020)
23896        .with_import("claude-mem", "user_prompts:3")
23897        .with_metadata("prompt_number", "9");
23898        store.insert_event(&prompt).unwrap();
23899    }
23900
23901    #[test]
23902    fn graph_callers_query() {
23903        let dir = setup_graph_index();
23904        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23905        let callers = db.callers_of("helper").unwrap();
23906        assert_eq!(callers.len(), 1);
23907        assert_eq!(callers[0].caller_name, "main");
23908    }
23909
23910    #[test]
23911    fn graph_callees_query() {
23912        let dir = setup_graph_index();
23913        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23914        let callees = db.callees_of("main").unwrap();
23915        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23916        assert!(names.contains(&"helper"));
23917        assert!(names.contains(&"new"));
23918    }
23919
23920    #[test]
23921    fn graph_no_callers_returns_empty() {
23922        let dir = setup_graph_index();
23923        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23924        let callers = db.callers_of("nonexistent").unwrap();
23925        assert!(callers.is_empty());
23926    }
23927
23928    #[test]
23929    fn graph_cmd_autoindexes_missing_index_by_default() {
23930        let dir = tempfile::tempdir().unwrap();
23931        std::fs::write(
23932            dir.path().join("main.rs"),
23933            "fn helper() {}\nfn main() { helper(); }\n",
23934        )
23935        .unwrap();
23936        let result = cmd_graph(
23937            "helper",
23938            dir.path(),
23939            true,
23940            false,
23941            None,
23942            20,
23943            false,
23944            true,
23945            false,
23946            false,
23947            false,
23948            false,
23949            false,
23950            TagpathSearchOpts::default(),
23951        );
23952
23953        assert!(result.is_ok());
23954        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23955        let summary = db.compute_changes(dir.path()).unwrap();
23956        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23957    }
23958
23959    #[test]
23960    fn traversal_graph_has_stable_typed_handles() {
23961        let dir = setup_traversal_project();
23962        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23963        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23964
23965        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23966        let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23967        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23968        let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23969
23970        assert!(file.handle.starts_with("gfil-"));
23971        assert!(symbol.handle.starts_with("gsym-"));
23972        assert!(backlog.handle.starts_with("gbak-"));
23973        assert!(session.handle.starts_with("gses-"));
23974
23975        assert_eq!(
23976            symbol.handle,
23977            resolve_traversal_node(&graph_again, "helper")
23978                .unwrap()
23979                .handle
23980        );
23981        assert_eq!(
23982            backlog.handle,
23983            resolve_traversal_node(&graph_again, "#kgnv")
23984                .unwrap()
23985                .handle
23986        );
23987    }
23988
23989    #[test]
23990    fn traversal_graph_links_backlog_items_to_code_tokens() {
23991        let dir = setup_traversal_project();
23992        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23993        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23994        let helper = resolve_traversal_node(&graph, "helper").unwrap();
23995
23996        assert!(graph.edges.iter().any(|edge| {
23997            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23998        }));
23999    }
24000
24001    #[test]
24002    fn session_hinted_traversal_skips_global_call_edges() {
24003        let dir = setup_traversal_project();
24004        let session = dir.path().join("tasks/software/tsift.md");
24005        let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
24006        let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
24007        let helper = resolve_traversal_node(&bounded, "helper").unwrap();
24008
24009        assert!(bounded.edges.iter().any(|edge| {
24010            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24011        }));
24012        assert!(
24013            !bounded.edges.iter().any(|edge| edge.relation == "calls"),
24014            "session-hinted graph-db projections should not materialize unrelated global call edges"
24015        );
24016
24017        let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24018        assert!(
24019            full.edges.iter().any(|edge| edge.relation == "calls"),
24020            "root/full projections still carry the complete indexed call graph"
24021        );
24022    }
24023
24024    #[test]
24025    fn agent_doc_task_path_infers_matching_workspace_scope() {
24026        let dir = tempfile::tempdir().unwrap();
24027        std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
24028        std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
24029        std::fs::write(
24030            dir.path().join(".gitmodules"),
24031            "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
24032        )
24033        .unwrap();
24034        let task = dir.path().join("tasks/software/tsift.md");
24035        std::fs::write(&task, "# tsift\n").unwrap();
24036
24037        let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
24038        let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
24039        let cfg = config::Config::load(dir.path()).unwrap();
24040
24041        assert_eq!(targets.len(), 1);
24042        assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
24043        assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
24044        assert!(
24045            targets[0]
24046                .db_path
24047                .ends_with(".tsift/indexes/tsift/index.db")
24048        );
24049        assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
24050    }
24051
24052    #[test]
24053    fn cargo_package_scope_selector_indexes_package_db() {
24054        let dir = setup_multiplicity_project();
24055        let targets =
24056            resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
24057
24058        assert_eq!(targets.len(), 1);
24059        assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
24060        assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
24061        assert!(
24062            targets[0]
24063                .db_path
24064                .ends_with(".tsift/indexes/cargo/core-lib/index.db")
24065        );
24066
24067        cmd_index(
24068            dir.path(),
24069            false,
24070            false,
24071            false,
24072            false,
24073            true,
24074            false,
24075            Some("core_lib"),
24076            false,
24077            true,
24078            false,
24079            false,
24080            false,
24081            false,
24082        )
24083        .unwrap();
24084        assert!(targets[0].db_path.exists());
24085    }
24086
24087    #[test]
24088    fn source_read_symbols_build_cargo_package_index_on_demand() {
24089        // A workspace member that has never been queried has no per-package cargo
24090        // index yet. source-read must build it on demand and return AST symbol
24091        // refs rather than silently degrading to window-only output with an
24092        // "index refs unavailable" warning while `tsift status` reports fresh
24093        // (#cargoidxcov).
24094        let dir = setup_multiplicity_project();
24095        let cargo_index = dir.path().join(".tsift/indexes/cargo/core-lib/index.db");
24096        assert!(
24097            !cargo_index.exists(),
24098            "core-lib cargo index should not exist before the first source-read"
24099        );
24100
24101        let file_abs = dir.path().join("crates/core-lib/src/lib.rs");
24102        let source = std::fs::read(&file_abs).unwrap();
24103        let mut warnings = Vec::new();
24104        let symbols = load_source_symbols(
24105            dir.path(),
24106            &file_abs,
24107            "crates/core-lib/src/lib.rs",
24108            &source,
24109            None,
24110            1,
24111            usize::MAX,
24112            10,
24113            4096,
24114            &mut warnings,
24115        );
24116
24117        assert!(
24118            warnings.is_empty(),
24119            "source-read must build the index on demand instead of warning: {warnings:?}"
24120        );
24121        let symbol_names = symbols
24122            .iter()
24123            .map(|symbol| symbol.name.as_str())
24124            .collect::<Vec<_>>();
24125        assert!(
24126            symbol_names.contains(&"run"),
24127            "source-read should resolve `run` from the on-demand-built cargo index: {symbol_names:?}"
24128        );
24129        assert!(
24130            cargo_index.exists(),
24131            "source-read should have built the core-lib cargo index on demand"
24132        );
24133    }
24134
24135    #[test]
24136    fn path_inference_prefers_nested_cargo_package_without_submodule() {
24137        let dir = setup_multiplicity_project();
24138        let source = dir.path().join("crates/cli-app/src/main.rs");
24139        let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
24140
24141        assert_eq!(targets.len(), 1);
24142        assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
24143        assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
24144    }
24145
24146    #[test]
24147    fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
24148        let dir = setup_multiplicity_project();
24149        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24150        let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
24151        let core = resolve_traversal_node(&graph, "core-lib").unwrap();
24152        let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
24153        let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
24154
24155        assert_eq!(workspace.kind, "cargo_workspace");
24156        assert_eq!(core.kind, "cargo_package");
24157        assert_eq!(
24158            core.properties.get("features"),
24159            Some(&"default".to_string())
24160        );
24161        assert!(graph.edges.iter().any(|edge| {
24162            edge.from == workspace.handle
24163                && edge.to == core.handle
24164                && edge.relation == "contains_package"
24165        }));
24166        assert!(graph.edges.iter().any(|edge| {
24167            edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
24168        }));
24169        assert!(graph.edges.iter().any(|edge| {
24170            edge.from == cli.handle
24171                && edge.to == core.handle
24172                && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
24173        }));
24174    }
24175
24176    #[test]
24177    fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
24178        let dir = setup_multiplicity_project();
24179        let session = dir.path().join("tasks/software/tsift.md");
24180        let report =
24181            build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
24182                .unwrap();
24183
24184        assert!(report.per_target_fail_closed.is_empty());
24185        let candidate = report
24186            .candidates
24187            .iter()
24188            .find(|candidate| candidate.target == "corepkg")
24189            .unwrap();
24190        assert!(
24191            candidate
24192                .owned_files
24193                .iter()
24194                .any(|file| file == "crates/core-lib/Cargo.toml"),
24195            "{:?}",
24196            candidate.owned_files
24197        );
24198    }
24199
24200    #[test]
24201    fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
24202        let dir = setup_traversal_project();
24203        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24204        let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
24205        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24206
24207        assert_eq!(job.kind, "job_packet");
24208        assert!(job.handle.starts_with("gjob-"));
24209        assert!(graph.edges.iter().any(|edge| {
24210            edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
24211        }));
24212
24213        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24214        let jobs = store.nodes_by_kind("job_packet").unwrap();
24215        assert!(
24216            jobs.iter()
24217                .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24218            "expected queued job packet in graph store, got {jobs:?}"
24219        );
24220    }
24221
24222    #[test]
24223    fn traversal_graph_includes_routes_and_handler_edges() {
24224        let dir = tempfile::tempdir().unwrap();
24225        std::fs::write(
24226            dir.path().join("api.py"),
24227            r#"@router.get("/items")
24228def list_items():
24229    return []
24230"#,
24231        )
24232        .unwrap();
24233        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24234        db.apply_changes(dir.path()).unwrap();
24235
24236        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24237        let route = resolve_traversal_node(&graph, "/items").unwrap();
24238        let handler = resolve_traversal_node(&graph, "list_items").unwrap();
24239
24240        assert_eq!(route.kind, "route");
24241        assert!(graph.edges.iter().any(|edge| {
24242            edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
24243        }));
24244    }
24245
24246    #[test]
24247    fn traversal_graph_projects_rust_ast_navigation_edges() {
24248        let dir = tempfile::tempdir().unwrap();
24249        std::fs::write(
24250            dir.path().join("main.rs"),
24251            r#"mod api {
24252    pub fn helper() {}
24253    pub fn handler() { helper(); }
24254}
24255
24256fn main() { api::handler(); }
24257"#,
24258        )
24259        .unwrap();
24260        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24261        db.apply_changes(dir.path()).unwrap();
24262
24263        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24264        let api = resolve_ast_span_node(&graph, "api", "mod");
24265        let helper = resolve_ast_span_node(&graph, "helper", "function");
24266        let handler = resolve_ast_span_node(&graph, "handler", "function");
24267
24268        assert_eq!(helper.kind, "ast_span");
24269        assert!(helper.handle.starts_with("span-"));
24270        assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
24271        assert!(graph.edges.iter().any(|edge| {
24272            edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
24273        }));
24274        assert!(graph.edges.iter().any(|edge| {
24275            edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
24276        }));
24277        assert!(graph.edges.iter().any(|edge| {
24278            edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
24279        }));
24280        assert!(graph.edges.iter().any(|edge| {
24281            edge.from == helper.handle
24282                && edge.to == handler.handle
24283                && edge.relation == "next_sibling"
24284        }));
24285        assert!(graph.edges.iter().any(|edge| {
24286            edge.from == handler.handle
24287                && edge.to == helper.handle
24288                && edge.relation == "previous_sibling"
24289        }));
24290        assert!(graph.edges.iter().any(|edge| {
24291            edge.from == helper.handle
24292                && edge.to == api.handle
24293                && edge.relation == "enclosing_module"
24294        }));
24295        assert!(graph.edges.iter().any(|edge| {
24296            edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
24297        }));
24298
24299        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24300        let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
24301        assert!(
24302            ast_nodes.iter().any(|node| node.id == helper.handle
24303                && node.properties.get("symbol_kind") == Some(&"function".to_string())),
24304            "expected helper AST span in graph store, got {ast_nodes:?}"
24305        );
24306        assert!(
24307            store
24308                .outgoing_edges(&helper.handle, Some("parent"))
24309                .unwrap()
24310                .iter()
24311                .any(|edge| edge.to_id == api.handle),
24312            "expected persisted AST parent edge"
24313        );
24314    }
24315
24316    #[test]
24317    fn traversal_graph_projects_markdown_section_block_edges() {
24318        let dir = tempfile::tempdir().unwrap();
24319        std::fs::write(
24320            dir.path().join("README.md"),
24321            "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
24322        )
24323        .unwrap();
24324        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24325        db.apply_changes(dir.path()).unwrap();
24326
24327        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24328        let guide = resolve_ast_span_node(&graph, "Guide", "heading");
24329        let code = resolve_ast_span_node(&graph, "rust", "code_block");
24330        let embedded = resolve_ast_span_node(&graph, "demo", "function");
24331        let list_item = graph
24332            .nodes
24333            .values()
24334            .find(|node| {
24335                node.kind == "ast_span"
24336                    && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
24337                    && node.properties.get("section_handle") == Some(&guide.handle)
24338            })
24339            .expect("missing Markdown list item AST span");
24340
24341        assert_eq!(
24342            code.properties.get("markdown_block_kind"),
24343            Some(&"fenced_code_block".to_string())
24344        );
24345        assert_eq!(
24346            guide.properties.get("heading_level"),
24347            Some(&"1".to_string())
24348        );
24349        assert_eq!(
24350            embedded.properties.get("embedded"),
24351            Some(&"true".to_string())
24352        );
24353        assert_eq!(
24354            embedded.properties.get("language"),
24355            Some(&"rust".to_string())
24356        );
24357        assert_eq!(
24358            embedded.properties.get("markdown_block_handle"),
24359            Some(&code.handle)
24360        );
24361        assert!(graph.edges.iter().any(|edge| {
24362            edge.from == guide.handle
24363                && edge.to == code.handle
24364                && edge.relation == "contains_markdown_block"
24365        }));
24366        assert!(graph.edges.iter().any(|edge| {
24367            edge.from == code.handle
24368                && edge.to == guide.handle
24369                && edge.relation == "enclosing_section"
24370        }));
24371        assert!(graph.edges.iter().any(|edge| {
24372            edge.from == guide.handle
24373                && edge.to == list_item.handle
24374                && edge.relation == "contains_markdown_block"
24375        }));
24376        assert!(graph.edges.iter().any(|edge| {
24377            edge.from == code.handle
24378                && edge.to == embedded.handle
24379                && edge.relation == "contains_embedded_symbol"
24380        }));
24381        assert!(graph.edges.iter().any(|edge| {
24382            edge.from == embedded.handle
24383                && edge.to == code.handle
24384                && edge.relation == "embedded_in_fence"
24385        }));
24386        assert!(graph.edges.iter().any(|edge| {
24387            edge.from == guide.handle
24388                && edge.to == embedded.handle
24389                && edge.relation == "contains_embedded_code"
24390        }));
24391
24392        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24393        assert!(
24394            store
24395                .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
24396                .unwrap()
24397                .iter()
24398                .any(|edge| edge.to_id == code.handle),
24399            "expected persisted Markdown section/block edge"
24400        );
24401        assert!(
24402            store
24403                .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
24404                .unwrap()
24405                .iter()
24406                .any(|edge| edge.to_id == embedded.handle),
24407            "expected persisted Markdown fence/embedded symbol edge"
24408        );
24409    }
24410
24411    #[test]
24412    fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
24413        let dir = setup_multilingual_ast_navigation_project();
24414        let db =
24415            index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
24416        let symbols = db.all_symbols().unwrap();
24417        let expected_symbols = [
24418            ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
24419            (
24420                "python",
24421                "fixture_nav_python_entry",
24422                "function",
24423                "python.py",
24424            ),
24425            (
24426                "typescript",
24427                "fixture_nav_typescript_entry",
24428                "function",
24429                "typescript.ts",
24430            ),
24431            (
24432                "javascript",
24433                "fixture_nav_javascript_entry",
24434                "function",
24435                "javascript.js",
24436            ),
24437            (
24438                "kotlin",
24439                "fixture_nav_kotlin_entry",
24440                "function",
24441                "kotlin.kt",
24442            ),
24443            ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
24444            ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
24445            ("markdown", "Fixture Section", "heading", "README.md"),
24446            ("markdown", "Fixture step", "list_item", "README.md"),
24447            ("markdown", "python", "code_block", "README.md"),
24448        ];
24449
24450        for (language, name, kind, file) in expected_symbols {
24451            let symbol = symbols
24452                .iter()
24453                .find(|symbol| {
24454                    symbol.language == language
24455                        && symbol.name == name
24456                        && symbol.kind == kind
24457                        && symbol.file.ends_with(file)
24458                })
24459                .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
24460            assert!(
24461                symbol.start_byte.is_some() && symbol.end_byte.is_some(),
24462                "{language} {name} should carry AST byte spans"
24463            );
24464        }
24465
24466        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24467        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24468        let expected_ast_nodes = [
24469            ("fixture_nav_rust_entry", "function", "rust"),
24470            ("fixture_nav_python_entry", "function", "python"),
24471            ("fixture_nav_typescript_entry", "function", "typescript"),
24472            ("fixture_nav_javascript_entry", "function", "javascript"),
24473            ("fixture_nav_kotlin_entry", "function", "kotlin"),
24474            ("fixture_nav_zig_entry", "function", "zig"),
24475            ("fixture_nav_bash_entry", "function", "bash"),
24476            ("Fixture Section", "heading", "markdown"),
24477            ("Fixture step", "list_item", "markdown"),
24478            ("python", "code_block", "markdown"),
24479            ("fixture_nav_markdown_embedded", "function", "python"),
24480        ];
24481
24482        for (name, kind, language) in expected_ast_nodes {
24483            let node = resolve_ast_span_node(&graph, name, kind);
24484            let repeated = resolve_ast_span_node(&graph_again, name, kind);
24485            assert!(
24486                node.handle.starts_with("span-"),
24487                "{name} handle: {}",
24488                node.handle
24489            );
24490            assert_eq!(
24491                node.handle, repeated.handle,
24492                "{language} {name} handle drifted"
24493            );
24494            assert_eq!(
24495                node.properties.get("language"),
24496                Some(&language.to_string()),
24497                "{name} should keep its language label"
24498            );
24499        }
24500
24501        let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
24502        let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
24503        let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
24504        assert!(graph.edges.iter().any(|edge| {
24505            edge.from == markdown_section.handle
24506                && edge.to == markdown_code.handle
24507                && edge.relation == "contains_markdown_block"
24508        }));
24509        assert!(graph.edges.iter().any(|edge| {
24510            edge.from == markdown_code.handle
24511                && edge.to == embedded.handle
24512                && edge.relation == "contains_embedded_symbol"
24513        }));
24514        assert!(
24515            graph.nodes.len() <= 80,
24516            "multilingual AST fixture should stay bounded, got {} nodes",
24517            graph.nodes.len()
24518        );
24519        assert!(
24520            graph.edges.len() <= 180,
24521            "multilingual AST fixture should stay bounded, got {} edges",
24522            graph.edges.len()
24523        );
24524
24525        let response = empty_search_response(dir.path(), "lexical");
24526        let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
24527        let report = build_relative_search_budget_report(
24528            "fixture_nav_python_entry",
24529            "lexical",
24530            dir.path(),
24531            &response,
24532            &symbol_hits,
24533            ResponseBudget::new(Some(8), Some(120)),
24534            &SearchFacetFilters::default(),
24535        );
24536        let report_again = build_relative_search_budget_report(
24537            "fixture_nav_python_entry",
24538            "lexical",
24539            dir.path(),
24540            &response,
24541            &symbol_hits,
24542            ResponseBudget::new(Some(8), Some(120)),
24543            &SearchFacetFilters::default(),
24544        );
24545
24546        let top = report
24547            .ranked
24548            .first()
24549            .expect("ranked preview should not be empty");
24550        assert_eq!(top.source, "symbol_span");
24551        assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
24552        assert!(top.handle.starts_with("srnk-"));
24553        assert_eq!(top.handle, report_again.ranked[0].handle);
24554        assert!(
24555            top.reasons.iter().any(|reason| reason == "ast_span"),
24556            "expected AST span ranking reason, got {:?}",
24557            top.reasons
24558        );
24559        assert!(report.ranked.len() <= 8);
24560        assert!(report.symbols.len() <= 8);
24561
24562        let symbol = report
24563            .symbols
24564            .iter()
24565            .find(|symbol| symbol.name == "fixture_nav_python_entry")
24566            .expect("missing search preview symbol");
24567        assert_cli_expand_command_parses(&symbol.expand);
24568        let ast = symbol
24569            .ast
24570            .as_ref()
24571            .expect("search symbol should expose AST");
24572        assert_cli_expand_command_parses(&ast.expand.source_window);
24573        assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
24574        assert_cli_expand_command_parses(&ast.expand.symbol_read);
24575
24576        let markdown_hits = db.symbol_search("python", 20).unwrap();
24577        let markdown_report = build_relative_search_budget_report(
24578            "python",
24579            "lexical",
24580            dir.path(),
24581            &response,
24582            &markdown_hits,
24583            ResponseBudget::new(Some(8), Some(120)),
24584            &SearchFacetFilters::default(),
24585        );
24586        let markdown_symbol = markdown_report
24587            .symbols
24588            .iter()
24589            .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
24590            .expect("missing Markdown code-block symbol");
24591        let markdown_ast = markdown_symbol
24592            .ast
24593            .as_ref()
24594            .expect("Markdown code block should expose AST");
24595        assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
24596        assert_eq!(
24597            markdown_ast
24598                .span
24599                .markdown
24600                .as_ref()
24601                .unwrap()
24602                .embedded_symbols[0]
24603                .name,
24604            "fixture_nav_markdown_embedded"
24605        );
24606    }
24607
24608    #[test]
24609    fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
24610        let edges = vec![
24611            TraversalEdge {
24612                from: "origin".to_string(),
24613                to: "aaa_low".to_string(),
24614                relation: "unknown".to_string(),
24615                label: None,
24616                weight: 1,
24617            },
24618            TraversalEdge {
24619                from: "origin".to_string(),
24620                to: "zzz_high".to_string(),
24621                relation: "mentions".to_string(),
24622                label: None,
24623                weight: 1,
24624            },
24625        ];
24626
24627        let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
24628
24629        assert!(handles.contains("origin"));
24630        assert!(handles.contains("zzz_high"), "{handles:?}");
24631        assert!(!handles.contains("aaa_low"), "{handles:?}");
24632    }
24633
24634    #[test]
24635    fn traversal_materializes_provider_neutral_sqlite_graph() {
24636        let dir = setup_traversal_project();
24637        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24638        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24639
24640        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24641        let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
24642        assert!(
24643            backlog_nodes.iter().any(|node| node.id == backlog.handle
24644                && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24645            "expected materialized backlog node, got {backlog_nodes:?}"
24646        );
24647        assert!(
24648            store
24649                .all_nodes()
24650                .unwrap()
24651                .iter()
24652                .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
24653                    && node.properties.get("projection_version")
24654                        == Some(&GRAPH_PROJECTION_VERSION.to_string())),
24655            "expected projection metadata node"
24656        );
24657        let source_handles = store.nodes_by_kind("source_handle").unwrap();
24658        assert!(
24659            source_handles
24660                .iter()
24661                .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
24662            "expected bounded source_handle rows, got {source_handles:?}"
24663        );
24664        let worker_context = store.nodes_by_kind("worker_context").unwrap();
24665        assert!(
24666            worker_context
24667                .iter()
24668                .any(|node| node.properties.get("target")
24669                    == Some(&"tasks/software/tsift.md".to_string())),
24670            "expected bounded worker_context rows, got {worker_context:?}"
24671        );
24672        let worker_results = store.nodes_by_kind("worker_result").unwrap();
24673        assert!(
24674            worker_results.iter().any(|node| {
24675                node.properties.get("ref_id") == Some(&"kgnv".to_string())
24676                    && node.properties.get("status") == Some(&"completed".to_string())
24677                    && node.properties.get("touched_files") == Some(&"main.rs".to_string())
24678                    && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
24679            }),
24680            "expected worker_result rows, got {worker_results:?}"
24681        );
24682    }
24683
24684    #[test]
24685    fn traversal_projection_materializes_cached_semantic_rows() {
24686        let dir = setup_traversal_project();
24687        seed_traversal_semantic_summaries(dir.path());
24688        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24689        let helper = resolve_traversal_node(&graph, "helper").unwrap();
24690        let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
24691        let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
24692
24693        assert_eq!(concept.kind, "semantic_concept");
24694        assert_eq!(entity.kind, "semantic_entity");
24695        assert!(concept.handle.starts_with("gcon-"));
24696        assert!(entity.handle.starts_with("gent-"));
24697
24698        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24699        assert!(
24700            store
24701                .nodes_by_kind("semantic_concept")
24702                .unwrap()
24703                .iter()
24704                .any(|node| node.label == "semantic extraction"
24705                    && node.properties.contains_key("embedding")),
24706            "expected persisted concept embeddings"
24707        );
24708        assert!(
24709            store
24710                .outgoing_edges(&helper.handle, Some("mentions_concept"))
24711                .unwrap()
24712                .iter()
24713                .any(|edge| edge.to_id == concept.handle),
24714            "expected helper symbol to link to cached summary concept"
24715        );
24716        assert!(
24717            store
24718                .outgoing_edges(
24719                    &semantic_entity_handle("helper", "function"),
24720                    Some("semantic_relation")
24721                )
24722                .unwrap()
24723                .iter()
24724                .any(|edge| edge.to_id == entity.handle
24725                    && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24726            "expected LLM relationship rows projected into GraphStore"
24727        );
24728    }
24729
24730    #[test]
24731    fn traversal_projection_materializes_tsift_memory_rows() {
24732        let dir = setup_traversal_project();
24733        seed_tsift_memory_graph_db(dir.path());
24734        let memory_db = dir.path().join(".tsift").join("memory.db");
24735        let store = MemoryStore::open_or_create(&memory_db).unwrap();
24736        for summary in ["first closeout", "second closeout"] {
24737            let event = MemoryEvent::new(
24738                MemoryEventKind::ResponseSummary,
24739                "tasks/software/tsift.md",
24740                summary,
24741            )
24742            .with_session_id("tasks/software/tsift.md")
24743            .with_observed_at_unix(1_700_000_100);
24744            store.insert_event(&event).unwrap();
24745        }
24746        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24747        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24748
24749        let native_sources = store
24750            .nodes_by_kind("source_handle")
24751            .unwrap()
24752            .into_iter()
24753            .filter(|node| {
24754                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24755                    && node.properties.get("source_ref")
24756                        == Some(&"tasks/software/tsift.md".to_string())
24757            })
24758            .collect::<Vec<_>>();
24759        assert_eq!(
24760            native_sources.len(),
24761            2,
24762            "same-source native memory events must get distinct source handles"
24763        );
24764
24765        let source = store
24766            .nodes_by_kind("source_handle")
24767            .unwrap()
24768            .into_iter()
24769            .find(|node| {
24770                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24771            })
24772            .expect("expected tsift-memory source handle");
24773        let session = store
24774            .nodes_by_kind("memory_session")
24775            .unwrap()
24776            .into_iter()
24777            .find(|node| {
24778                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24779                    && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24780            })
24781            .expect("expected tsift-memory session node");
24782        let event = store
24783            .nodes_by_kind("memory_event")
24784            .unwrap()
24785            .into_iter()
24786            .find(|node| {
24787                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24788                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24789                    && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24790            })
24791            .expect("expected tsift-memory event node");
24792        let concept = store
24793            .nodes_by_kind("semantic_concept")
24794            .unwrap()
24795            .into_iter()
24796            .find(|node| {
24797                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24798                    && node.label.contains("Graph memory adapter")
24799                    && node.properties.contains_key("embedding")
24800            })
24801            .expect("expected tsift-memory semantic concept");
24802
24803        assert!(
24804            store
24805                .outgoing_edges(&session.id, Some("records_memory_source"))
24806                .unwrap()
24807                .iter()
24808                .any(|edge| edge.to_id == source.id),
24809            "expected session to link to source handle"
24810        );
24811        assert!(
24812            store
24813                .outgoing_edges(&session.id, Some("records_memory_event"))
24814                .unwrap()
24815                .iter()
24816                .any(|edge| edge.to_id == event.id),
24817            "expected session to link to memory event"
24818        );
24819        assert!(
24820            store
24821                .outgoing_edges(&event.id, Some("projects_source"))
24822                .unwrap()
24823                .iter()
24824                .any(|edge| edge.to_id == source.id),
24825            "expected memory event to project source handle"
24826        );
24827        assert!(
24828            store
24829                .outgoing_edges(&source.id, Some("mentions_concept"))
24830                .unwrap()
24831                .iter()
24832                .any(|edge| edge.to_id == concept.id),
24833            "expected source handle to seed semantic concept"
24834        );
24835
24836        let related = semantic_related_report_from_store(
24837            dir.path(),
24838            None,
24839            "tsift memory graph adapter",
24840            5,
24841            SemanticRelatedKind::Concept,
24842            &store,
24843        )
24844        .unwrap();
24845        assert!(
24846            related
24847                .items
24848                .iter()
24849                .any(|item| item.handle == concept.id && item.score > 0.0),
24850            "expected semantic query to retrieve tsift-memory concept, got {:?}",
24851            related.items
24852        );
24853
24854        let graph_related = graph_db_report_from_store(
24855            dir.path(),
24856            None,
24857            "sqlite",
24858            GraphDbQuery::Related {
24859                query: "tsift memory graph adapter".to_string(),
24860                kind: SemanticRelatedKind::Concept,
24861                depth: 1,
24862                seed_limit: 5,
24863                limit: 20,
24864            },
24865            &store,
24866            sqlite_graph_freshness(&store, "root").unwrap(),
24867            Vec::new(),
24868        )
24869        .unwrap();
24870        assert_eq!(
24871            graph_related
24872                .readiness
24873                .as_ref()
24874                .map(|readiness| readiness.status.as_str()),
24875            Some("ready"),
24876            "tsift-memory semantic rows should satisfy graph-db related readiness"
24877        );
24878        assert!(
24879            graph_related.nodes.iter().any(|node| {
24880                node.kind == "semantic_concept"
24881                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24882            }),
24883            "expected related graph output to include tsift-memory semantic rows"
24884        );
24885    }
24886
24887    #[test]
24888    fn semantic_related_query_uses_persisted_graph_embeddings() {
24889        let dir = setup_traversal_project();
24890        seed_traversal_semantic_summaries(dir.path());
24891        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24892        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24893        let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
24894            .unwrap()
24895            .query_row(
24896                "SELECT COUNT(*) FROM graph_node_semantic_vectors",
24897                [],
24898                |row| row_usize(row, 0),
24899            )
24900            .unwrap();
24901        assert!(semantic_vector_rows > 0);
24902
24903        let report = semantic_related_report_from_store(
24904            dir.path(),
24905            None,
24906            "graph navigation",
24907            5,
24908            SemanticRelatedKind::Concept,
24909            &store,
24910        )
24911        .unwrap();
24912
24913        assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
24914        assert!(
24915            report
24916                .items
24917                .iter()
24918                .any(|item| item.label == "graph navigation"
24919                    && item.kind == "semantic_concept"
24920                    && item.score > 0.9),
24921            "expected nearest concept match from graph embeddings, got {:?}",
24922            report.items
24923        );
24924    }
24925
24926    #[test]
24927    fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
24928        let dir = setup_traversal_project();
24929        seed_traversal_semantic_summaries(dir.path());
24930        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24931        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24932
24933        let report = graph_db_report_from_store(
24934            dir.path(),
24935            None,
24936            "sqlite",
24937            GraphDbQuery::Related {
24938                query: "graph navigation".to_string(),
24939                kind: SemanticRelatedKind::All,
24940                depth: 1,
24941                seed_limit: 2,
24942                limit: 20,
24943            },
24944            &store,
24945            sqlite_graph_freshness(&store, "root").unwrap(),
24946            Vec::new(),
24947        )
24948        .unwrap();
24949
24950        let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24951        assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24952        assert_eq!(knowledge.seed_kind, "all");
24953        assert_eq!(knowledge.depth, 1);
24954        assert_eq!(
24955            report
24956                .readiness
24957                .as_ref()
24958                .map(|readiness| readiness.status.as_str()),
24959            Some("ready")
24960        );
24961        assert!(
24962            knowledge
24963                .diagnostics
24964                .iter()
24965                .any(|diagnostic| diagnostic.contains("incident"))
24966        );
24967        assert!(
24968            report
24969                .semantic_related
24970                .iter()
24971                .any(|item| item.label == "graph navigation"
24972                    && item.kind == "semantic_concept"
24973                    && item.score > 0.9),
24974            "expected natural-language query to seed the graph navigation concept, got {:?}",
24975            report.semantic_related
24976        );
24977        assert!(
24978            report
24979                .nodes
24980                .iter()
24981                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
24982        );
24983        assert!(
24984            report
24985                .nodes
24986                .iter()
24987                .any(|node| node.kind == "symbol" && node.label == "helper"),
24988            "incident expansion from semantic seed should recover source symbols, got {:?}",
24989            report
24990                .nodes
24991                .iter()
24992                .map(|node| (&node.kind, &node.label))
24993                .collect::<Vec<_>>()
24994        );
24995        assert!(
24996            report
24997                .edges
24998                .iter()
24999                .any(|edge| edge.kind == "mentions_concept")
25000        );
25001        assert!(
25002            report.output_budget.as_ref().is_some_and(|budget| budget
25003                .diagnostics
25004                .iter()
25005                .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
25006            "expected related output budget diagnostics, got {:?}",
25007            report.output_budget
25008        );
25009    }
25010
25011    #[test]
25012    fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
25013        let dir = setup_graph_index();
25014        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25015        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25016
25017        let report = graph_db_report_from_store(
25018            dir.path(),
25019            None,
25020            "sqlite",
25021            GraphDbQuery::Related {
25022                query: "graph navigation".to_string(),
25023                kind: SemanticRelatedKind::All,
25024                depth: 1,
25025                seed_limit: 2,
25026                limit: 20,
25027            },
25028            &store,
25029            sqlite_graph_freshness(&store, "root").unwrap(),
25030            Vec::new(),
25031        )
25032        .unwrap();
25033
25034        let readiness = report.readiness.as_ref().unwrap();
25035        assert_eq!(readiness.status, "blocked");
25036        assert_eq!(readiness.reason, "summary_cache_empty");
25037        assert!(readiness.fail_closed);
25038        assert_eq!(
25039            readiness.next_commands,
25040            vec![
25041                "tsift summarize --extract .".to_string(),
25042                graph_db_refresh_command(dir.path(), None)
25043            ]
25044        );
25045        assert!(
25046            report
25047                .knowledge_retrieval
25048                .as_ref()
25049                .unwrap()
25050                .diagnostics
25051                .iter()
25052                .any(|diagnostic| diagnostic.contains("summary cache empty")
25053                    && diagnostic.contains("graph-db materialized code/session rows")),
25054            "expected related diagnostics to carry readiness gate, got {:?}",
25055            report.knowledge_retrieval.as_ref().unwrap().diagnostics
25056        );
25057    }
25058
25059    #[test]
25060    fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
25061        let mut nodes = vec![
25062            SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
25063            SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
25064        ];
25065        let mut edges = vec![SubstrateGraphEdge::new(
25066            "zzz_high",
25067            "seed",
25068            "mentions_concept",
25069        )];
25070        for idx in 0..24 {
25071            let id = format!("aaa_low_{idx:02}");
25072            nodes.push(SubstrateGraphNode::new(
25073                id.clone(),
25074                "note",
25075                format!("low {idx}"),
25076            ));
25077            edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
25078        }
25079        let mut store = SqliteGraphStore::in_memory().unwrap();
25080        store
25081            .replace_projection(&GraphProjection { nodes, edges })
25082            .unwrap();
25083
25084        let subgraph =
25085            graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
25086
25087        assert_eq!(subgraph.nodes.len(), 3);
25088        assert_eq!(subgraph.nodes[0].id, "seed");
25089        assert_eq!(
25090            subgraph.nodes[1].id, "zzz_high",
25091            "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
25092            subgraph.nodes
25093        );
25094        assert!(subgraph.truncated);
25095        assert!(
25096            subgraph
25097                .diagnostics
25098                .iter()
25099                .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
25100            "{:?}",
25101            subgraph.diagnostics
25102        );
25103        assert!(
25104            subgraph
25105                .diagnostics
25106                .iter()
25107                .any(|diagnostic| diagnostic.contains("skipped")),
25108            "{:?}",
25109            subgraph.diagnostics
25110        );
25111    }
25112
25113    #[test]
25114    fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
25115        let dir = setup_traversal_project();
25116        seed_traversal_semantic_summaries(dir.path());
25117        init_git_repo(dir.path());
25118        let session = dir.path().join("tasks/software/tsift.md");
25119        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25120        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25121        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25122        let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25123            root: dir.path(),
25124            scope: None,
25125            backend: "sqlite",
25126            target: "kgnv",
25127            preferred_path: None,
25128            depth: 4,
25129            limit: 8,
25130            cursor: None,
25131            store: &store,
25132            freshness,
25133            warnings: Vec::new(),
25134        })
25135        .unwrap();
25136        assert!(
25137            evidence
25138                .semantic_related
25139                .iter()
25140                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
25141            "expected semantic evidence rows, got {:?}",
25142            evidence
25143                .semantic_related
25144                .iter()
25145                .map(|node| (&node.kind, &node.label))
25146                .collect::<Vec<_>>()
25147        );
25148        assert!(
25149            evidence
25150                .output_budget
25151                .as_ref()
25152                .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
25153                    diagnostic.contains("semantic_match")
25154                        && diagnostic.contains("source_handle_coverage")
25155                })),
25156            "expected evidence output budget diagnostics, got {:?}",
25157            evidence.output_budget
25158        );
25159
25160        let cached_diff = diff_digest::compute(
25161            dir.path(),
25162            diff_digest::DiffDigestOptions {
25163                cached: true,
25164                revision: None,
25165                max_parsed_files: None,
25166            },
25167        )
25168        .unwrap();
25169        let impact_report = impact::compute(
25170            dir.path(),
25171            impact::ImpactOptions {
25172                cached: true,
25173                revision: None,
25174                scope: None,
25175                limit: 10,
25176            },
25177        )
25178        .unwrap();
25179        let graph_nodes = store.all_nodes().unwrap();
25180        let graph_index = conflict_matrix_graph_index(&graph_nodes);
25181        let semantic_candidate = conflict_matrix_candidate_from_evidence(
25182            dir.path(),
25183            &evidence,
25184            &graph_index,
25185            &cached_diff,
25186            &impact_report,
25187        );
25188        assert!(semantic_candidate.semantic_dispatch_score > 0);
25189        assert!(
25190            semantic_candidate
25191                .semantic_dispatch_reasons
25192                .iter()
25193                .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
25194            "expected semantic ranking explanations, got {:?}",
25195            semantic_candidate.semantic_dispatch_reasons
25196        );
25197        assert!(
25198            semantic_candidate
25199                .semantic_related
25200                .iter()
25201                .any(|item| item.label == "graph navigation")
25202        );
25203
25204        let mut plain_candidate = semantic_candidate.clone();
25205        plain_candidate.target = "plain".to_string();
25206        plain_candidate.semantic_related.clear();
25207        plain_candidate.semantic_dispatch_score = 0;
25208        plain_candidate.semantic_dispatch_reasons.clear();
25209        let mut ranked = [plain_candidate, semantic_candidate];
25210        ranked.sort_by(|left, right| {
25211            left.risk
25212                .cmp(&right.risk)
25213                .then_with(|| left.risk_score.cmp(&right.risk_score))
25214                .then_with(|| {
25215                    right
25216                        .semantic_dispatch_score
25217                        .cmp(&left.semantic_dispatch_score)
25218                })
25219                .then_with(|| left.target.cmp(&right.target))
25220        });
25221        assert_eq!(ranked[0].target, "kgnv");
25222    }
25223
25224    #[test]
25225    fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
25226        let dir = setup_dependency_dag_project();
25227        let session = dir.path().join("tasks/software/tsift.md");
25228        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25229
25230        assert_eq!(report.contract_version, "dependency-dag-v1");
25231        assert_eq!(
25232            report.targets,
25233            vec![
25234                "prep".to_string(),
25235                "alpha".to_string(),
25236                "beta".to_string(),
25237                "gamma".to_string()
25238            ]
25239        );
25240        assert!(report.edges.iter().any(|edge| {
25241            edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
25242        }));
25243        assert!(report.edges.iter().any(|edge| {
25244            edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
25245        }));
25246        assert!(report.edges.iter().any(|edge| {
25247            edge.from == "alpha"
25248                && edge.to == "beta"
25249                && edge.kind == "shared_resource"
25250                && edge.shared_files.contains(&"main.rs".to_string())
25251                && edge.shared_symbols.contains(&"shared_helper".to_string())
25252        }));
25253        assert!(
25254            !report.cycle_diagnostics.has_cycles,
25255            "{:?}",
25256            report.cycle_diagnostics
25257        );
25258        assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
25259        assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
25260        assert!(
25261            report.replay_commands[0].contains("dependency-dag"),
25262            "{:?}",
25263            report.replay_commands
25264        );
25265
25266        cmd_dependency_dag(
25267            &session,
25268            None,
25269            &["alpha".to_string(), "beta".to_string()],
25270            4,
25271            12,
25272            OutputFormat {
25273                json_output: true,
25274                compact: false,
25275                pretty: false,
25276                terse: false,
25277                ultra_terse: false,
25278                schema: false,
25279                envelope: false,
25280            },
25281        )
25282        .unwrap();
25283    }
25284
25285    #[test]
25286    fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
25287        let dir = setup_dependency_dag_cycle_project();
25288        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25289
25290        assert!(report.cycle_diagnostics.has_cycles);
25291        assert_eq!(
25292            report.cycle_diagnostics.blocked_nodes,
25293            vec!["left".to_string(), "right".to_string()]
25294        );
25295        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25296            edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
25297        }));
25298        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25299            edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
25300        }));
25301    }
25302
25303    #[test]
25304    fn traversal_projection_queries_match_sqlite_and_convex_stores() {
25305        let dir = setup_traversal_project();
25306        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
25307        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
25308
25309        let mut sqlite = SqliteGraphStore::in_memory().unwrap();
25310        sqlite.replace_projection(&projection).unwrap();
25311        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25312        projection.upsert_into(&convex).unwrap();
25313
25314        let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
25315        let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
25316        assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
25317        assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
25318
25319        let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
25320        let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
25321        assert!(convex_graph.edges.iter().any(|edge| {
25322            edge.from == sqlite_backlog.handle
25323                && edge.to == convex_helper.handle
25324                && edge.relation == "mentions"
25325        }));
25326    }
25327
25328    #[test]
25329    fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
25330        let dir = setup_traversal_project();
25331        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25332        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25333        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25334        assert_eq!(freshness.status, "current");
25335
25336        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25337        let report = graph_db_report_from_store(
25338            dir.path(),
25339            None,
25340            "sqlite",
25341            GraphDbQuery::Neighborhood {
25342                id: backlog.handle.clone(),
25343                depth: 1,
25344                edge_kind: Some("mentions".to_string()),
25345                cursor: None,
25346                limit: None,
25347                property_filters: Vec::new(),
25348            },
25349            &store,
25350            freshness,
25351            Vec::new(),
25352        )
25353        .unwrap();
25354        assert!(
25355            report
25356                .edges
25357                .iter()
25358                .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
25359            "expected backlog mention edge, got {:?}",
25360            report.edges
25361        );
25362        assert!(
25363            report.ranked_neighbors.iter().any(|neighbor| {
25364                neighbor.depth == Some(1)
25365                    && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
25366                    && neighbor.node_id != backlog.handle
25367                    && neighbor.handle_coverage_pct >= 95.0
25368                    && neighbor.duplicate_name_precision >= 0.99
25369            }),
25370            "expected ranked neighborhood neighbors with quality scores, got {:?}",
25371            report.ranked_neighbors
25372        );
25373        assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
25374        let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
25375        assert!(!ranking_gate.ranked_output_default);
25376        assert_eq!(ranking_gate.default_order, "stable_node_id");
25377        assert!(
25378            ranking_gate
25379                .diagnostics
25380                .iter()
25381                .any(|diagnostic| diagnostic.contains("score-capped")),
25382            "{ranking_gate:?}"
25383        );
25384        assert!(
25385            ranking_gate
25386                .required_metrics
25387                .iter()
25388                .any(|metric| metric == "handle_coverage_pct")
25389        );
25390        assert!(
25391            ranking_gate
25392                .required_metrics
25393                .iter()
25394                .any(|metric| metric == "duplicate_name_precision")
25395        );
25396        assert!(
25397            report
25398                .page
25399                .as_ref()
25400                .unwrap()
25401                .diagnostics
25402                .iter()
25403                .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
25404            "expected SQLite neighborhood query plan diagnostics, got {:?}",
25405            report.page.as_ref().unwrap().diagnostics
25406        );
25407        let edges_report = graph_db_report_from_store(
25408            dir.path(),
25409            None,
25410            "sqlite",
25411            GraphDbQuery::Edges {
25412                edge_kind: Some("mentions".to_string()),
25413                cursor: None,
25414                limit: Some(2),
25415                property_filters: Vec::new(),
25416            },
25417            &store,
25418            sqlite_graph_freshness(&store, "root").unwrap(),
25419            Vec::new(),
25420        )
25421        .unwrap();
25422        let edge_id = edges_report
25423            .edges
25424            .first()
25425            .map(|edge| edge.id.clone())
25426            .expect("expected at least one paged mentions edge");
25427        assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
25428        assert_eq!(
25429            edges_report.page.as_ref().unwrap().returned_edges,
25430            edges_report.edges.len()
25431        );
25432
25433        let edge_report = graph_db_report_from_store(
25434            dir.path(),
25435            None,
25436            "sqlite",
25437            GraphDbQuery::Edge {
25438                id: edge_id.clone(),
25439            },
25440            &store,
25441            sqlite_graph_freshness(&store, "root").unwrap(),
25442            Vec::new(),
25443        )
25444        .unwrap();
25445        assert_eq!(
25446            edge_report
25447                .edge
25448                .as_ref()
25449                .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
25450            Some(edge_id.clone())
25451        );
25452
25453        let incident_report = graph_db_report_from_store(
25454            dir.path(),
25455            None,
25456            "sqlite",
25457            GraphDbQuery::Incident {
25458                id: backlog.handle.clone(),
25459                edge_kind: Some("mentions".to_string()),
25460                cursor: None,
25461                limit: Some(1),
25462                property_filters: Vec::new(),
25463            },
25464            &store,
25465            sqlite_graph_freshness(&store, "root").unwrap(),
25466            Vec::new(),
25467        )
25468        .unwrap();
25469        assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
25470        assert!(
25471            incident_report
25472                .edges
25473                .iter()
25474                .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
25475            "{:?}",
25476            incident_report.edges
25477        );
25478
25479        let schema_report = graph_db_report_from_store(
25480            dir.path(),
25481            None,
25482            "sqlite",
25483            GraphDbQuery::Schema,
25484            &store,
25485            sqlite_graph_freshness(&store, "root").unwrap(),
25486            Vec::new(),
25487        )
25488        .unwrap();
25489        assert!(
25490            schema_report
25491                .schema
25492                .unwrap()
25493                .operations
25494                .iter()
25495                .any(|operation| operation.command.starts_with("neighborhood"))
25496        );
25497    }
25498
25499    #[test]
25500    fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
25501        let mut nodes = vec![SubstrateGraphNode::new(
25502            "origin",
25503            "backlog",
25504            "#budgeted-neighborhood",
25505        )];
25506        let mut edges = Vec::new();
25507        for idx in 0..32 {
25508            let id = format!("src-{idx:02}");
25509            nodes.push(
25510                SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
25511                    .with_property("source_ref", format!("fixture:{idx}"))
25512                    .with_property("detail", "x".repeat(600)),
25513            );
25514            edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
25515        }
25516        let store = SqliteGraphStore::in_memory().unwrap();
25517        GraphProjection { nodes, edges }
25518            .upsert_into(&store)
25519            .unwrap();
25520
25521        let report = graph_db_report_from_store(
25522            Path::new("."),
25523            None,
25524            "fixture",
25525            GraphDbQuery::Neighborhood {
25526                id: "origin".to_string(),
25527                depth: 1,
25528                edge_kind: None,
25529                cursor: None,
25530                limit: None,
25531                property_filters: Vec::new(),
25532            },
25533            &store,
25534            current_graph_db_freshness(),
25535            Vec::new(),
25536        )
25537        .unwrap();
25538        let budget = report.output_budget.as_ref().unwrap();
25539        assert!(budget.selected_nodes < budget.candidate_nodes);
25540        assert!(
25541            budget.dropped_by_budget.iter().any(|drop| {
25542                drop.item == "node"
25543                    && drop.kind == "source_handle"
25544                    && drop.reason == "per_kind_quota"
25545            }),
25546            "expected source_handle budget drops, got {:?}",
25547            budget.dropped_by_budget
25548        );
25549        assert!(report.page.as_ref().unwrap().truncated);
25550        assert!(
25551            report
25552                .page
25553                .as_ref()
25554                .unwrap()
25555                .diagnostics
25556                .iter()
25557                .any(|diagnostic| diagnostic.contains("budget ranking signals")),
25558            "{:?}",
25559            report.page
25560        );
25561    }
25562
25563    #[test]
25564    fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
25565        let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
25566        let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
25567        for idx in 0..8 {
25568            let id = format!("far-{idx:02}");
25569            nodes.push(SubstrateGraphNode::new(
25570                id.clone(),
25571                "note",
25572                format!("aaa deeper row {idx}"),
25573            ));
25574            depth_by_id.insert(id, 6);
25575        }
25576
25577        let origin_ids = vec!["target".to_string()];
25578        let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
25579            &origin_ids,
25580            &BTreeMap::new(),
25581            nodes,
25582            Vec::new(),
25583            Some(3),
25584            Some(&depth_by_id),
25585            None,
25586        );
25587
25588        assert!(
25589            budgeted.nodes.iter().any(|node| node.id == "near"),
25590            "expected the shallow evidence row to outrank deeper rows, got {:?}",
25591            budgeted
25592                .nodes
25593                .iter()
25594                .map(|node| (&node.id, &node.label))
25595                .collect::<Vec<_>>()
25596        );
25597        assert!(
25598            budgeted.report.dropped_by_budget.iter().any(|drop| {
25599                drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
25600            }),
25601            "expected node quota drops, got {:?}",
25602            budgeted.report.dropped_by_budget
25603        );
25604        assert!(
25605            budgeted
25606                .report
25607                .diagnostics
25608                .iter()
25609                .any(|diagnostic| diagnostic.contains("depth")),
25610            "{:?}",
25611            budgeted.report.diagnostics
25612        );
25613    }
25614
25615    #[test]
25616    fn evidence_pagination_returns_next_cursor_when_truncated() {
25617        let mut nodes = vec![SubstrateGraphNode::new(
25618            "target".to_string(),
25619            "backlog_item",
25620            "target item".to_string(),
25621        )];
25622        let mut depth_by_id = BTreeMap::new();
25623        depth_by_id.insert("target".to_string(), 0);
25624        for idx in 0..20 {
25625            let id = format!("ev-{idx}");
25626            nodes.push(
25627                SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
25628                    .with_property("detail", "x".repeat(400)),
25629            );
25630            depth_by_id.insert(id, 1);
25631        }
25632        let origin_ids = vec!["target".to_string()];
25633        let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
25634            &origin_ids,
25635            &BTreeMap::new(),
25636            nodes.clone(),
25637            Vec::new(),
25638            Some(3),
25639            Some(&depth_by_id),
25640            None,
25641        );
25642        assert!(
25643            first_page.truncated,
25644            "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
25645            first_page.nodes.len(),
25646            first_page.report.candidate_nodes
25647        );
25648        assert!(
25649            first_page.next_cursor.is_some(),
25650            "expected next_cursor when truncated"
25651        );
25652        let cursor = first_page.next_cursor.unwrap();
25653        assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
25654        let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
25655        let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
25656            &origin_ids,
25657            &BTreeMap::new(),
25658            nodes.clone(),
25659            Vec::new(),
25660            Some(3),
25661            Some(&depth_by_id),
25662            Some(&cursor),
25663        );
25664        let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
25665        let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
25666        assert!(
25667            overlap.is_empty(),
25668            "pages should not overlap, but found shared ids: {overlap:?}"
25669        );
25670        assert!(
25671            second_page
25672                .report
25673                .diagnostics
25674                .iter()
25675                .any(|d| d.contains("cursor skipped")),
25676            "expected cursor skip diagnostic, got {:?}",
25677            second_page.report.diagnostics
25678        );
25679    }
25680
25681    #[test]
25682    fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
25683        let mut nodes = vec![SubstrateGraphNode::new(
25684            "target".to_string(),
25685            "backlog_item",
25686            "target item".to_string(),
25687        )];
25688        let mut depth_by_id = BTreeMap::new();
25689        depth_by_id.insert("target".to_string(), 0);
25690        for idx in 0..3 {
25691            let id = format!("ev-{idx}");
25692            nodes.push(SubstrateGraphNode::new(
25693                id.clone(),
25694                "source_handle",
25695                format!("evidence row {idx}"),
25696            ));
25697            depth_by_id.insert(id, 1);
25698        }
25699        let origin_ids = vec!["target".to_string()];
25700        let result = graph_db_apply_output_budget_with_depths_and_cursor(
25701            &origin_ids,
25702            &BTreeMap::new(),
25703            nodes,
25704            Vec::new(),
25705            None,
25706            Some(&depth_by_id),
25707            None,
25708        );
25709        assert!(
25710            !result.truncated,
25711            "expected no truncation with small candidate set and default budget"
25712        );
25713        assert!(
25714            result.next_cursor.is_none(),
25715            "expected no next_cursor when not truncated"
25716        );
25717    }
25718
25719    #[test]
25720    fn evidence_pagination_invalid_cursor_returns_first_page() {
25721        let mut nodes = vec![SubstrateGraphNode::new(
25722            "target".to_string(),
25723            "backlog_item",
25724            "target item".to_string(),
25725        )];
25726        let mut depth_by_id = BTreeMap::new();
25727        depth_by_id.insert("target".to_string(), 0);
25728        for idx in 0..5 {
25729            let id = format!("ev-{idx}");
25730            nodes.push(SubstrateGraphNode::new(
25731                id.clone(),
25732                "source_handle",
25733                format!("evidence row {idx}"),
25734            ));
25735            depth_by_id.insert(id, 1);
25736        }
25737        let origin_ids = vec!["target".to_string()];
25738        let result = graph_db_apply_output_budget_with_depths_and_cursor(
25739            &origin_ids,
25740            &BTreeMap::new(),
25741            nodes.clone(),
25742            Vec::new(),
25743            None,
25744            Some(&depth_by_id),
25745            Some("nonexistent-id"),
25746        );
25747        assert!(
25748            result
25749                .report
25750                .diagnostics
25751                .iter()
25752                .any(|d| d.contains("cursor skipped 0")),
25753            "invalid cursor should skip 0 candidates, got {:?}",
25754            result.report.diagnostics
25755        );
25756    }
25757
25758    #[test]
25759    fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25760        let dir = setup_traversal_project();
25761        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25762        let graph_db = dir.path().join(".tsift/graph.db");
25763        let _lock = hold_rollback_journal_lock(&graph_db);
25764
25765        let report =
25766            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25767                .unwrap();
25768
25769        assert_eq!(report.status, "current");
25770        assert_eq!(
25771            report.recovery,
25772            Some(index::ReadOnlyRecovery::SnapshotFallback)
25773        );
25774        assert!(
25775            report
25776                .warnings
25777                .iter()
25778                .any(|warning| warning.contains("rollback-journal lock")),
25779            "expected rollback-journal recovery warning, got {:?}",
25780            report.warnings
25781        );
25782    }
25783
25784    #[test]
25785    fn graph_db_status_copies_wal_sidecars_when_locked() {
25786        let dir = setup_traversal_project();
25787        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25788        let graph_db = dir.path().join(".tsift/graph.db");
25789        let _lock = hold_wal_database_lock(&graph_db);
25790
25791        let report =
25792            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25793                .unwrap();
25794
25795        assert_eq!(report.status, "current");
25796        assert_eq!(
25797            report.recovery,
25798            Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25799        );
25800        assert!(
25801            report
25802                .warnings
25803                .iter()
25804                .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25805            "expected WAL recovery warning, got {:?}",
25806            report.warnings
25807        );
25808    }
25809
25810    #[test]
25811    fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
25812        let dir = setup_traversal_project();
25813        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25814        let graph_db = dir.path().join(".tsift/graph.db");
25815        let _lock = hold_rollback_journal_lock(&graph_db);
25816
25817        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25818        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25819        report.finalize();
25820
25821        assert_eq!(report.status, "ok");
25822        assert!(!report.fail_closed);
25823        let recovery_check = report
25824            .checks
25825            .iter()
25826            .find(|check| check.name == "sqlite_graph_db_read_recovery")
25827            .expect("doctor should include read recovery diagnostic");
25828        assert_eq!(recovery_check.status, "recovered");
25829        assert!(
25830            recovery_check
25831                .diagnostics
25832                .iter()
25833                .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
25834            "expected rollback-journal recovery diagnostic, got {:?}",
25835            recovery_check.diagnostics
25836        );
25837    }
25838
25839    #[test]
25840    fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
25841        let dir = setup_traversal_project();
25842        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25843        let graph_db = dir.path().join(".tsift/graph.db");
25844        let _lock = hold_wal_database_lock(&graph_db);
25845
25846        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25847        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25848        report.finalize();
25849
25850        assert_eq!(report.status, "ok");
25851        assert!(!report.fail_closed);
25852        let recovery_check = report
25853            .checks
25854            .iter()
25855            .find(|check| check.name == "sqlite_graph_db_read_recovery")
25856            .expect("doctor should include WAL read recovery diagnostic");
25857        assert_eq!(recovery_check.status, "recovered");
25858        assert!(
25859            recovery_check
25860                .diagnostics
25861                .iter()
25862                .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
25863            "expected WAL recovery diagnostic, got {:?}",
25864            recovery_check.diagnostics
25865        );
25866    }
25867
25868    #[test]
25869    fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
25870        let dir = setup_traversal_project();
25871        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25872        let artifact = dir.path().join("graph.db.gz");
25873
25874        let exported =
25875            commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
25876                .unwrap();
25877        let exported_projection_version = exported.freshness.projection_version.clone();
25878        let exported_content_hash = exported.freshness.content_hash.clone();
25879        let exported_source_watermark = exported.freshness.source_watermark.clone();
25880        let exported_nodes = exported.counts.nodes;
25881        let exported_edges = exported.counts.edges;
25882        assert_eq!(exported.operation, "snapshot-export");
25883        assert!(exported.status.starts_with("exported"));
25884        assert!(artifact.exists());
25885        assert!(exported.artifact_bytes > 0);
25886        assert_eq!(exported.compression, "gzip");
25887
25888        fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
25889
25890        let imported =
25891            commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
25892                .unwrap();
25893        assert_eq!(imported.operation, "snapshot-import");
25894        assert!(imported.status.starts_with("imported"));
25895        assert_eq!(
25896            imported.freshness.projection_version,
25897            exported_projection_version
25898        );
25899        assert_eq!(imported.freshness.content_hash, exported_content_hash);
25900        assert_eq!(
25901            imported.freshness.source_watermark,
25902            exported_source_watermark
25903        );
25904        assert_eq!(imported.counts.nodes, exported_nodes);
25905        assert_eq!(imported.counts.edges, exported_edges);
25906        assert!(dir.path().join(".tsift/graph.db").exists());
25907    }
25908
25909    #[test]
25910    fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
25911        let dir = setup_traversal_project();
25912        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25913        let graph_db = dir.path().join(".tsift/graph.db");
25914        let _lock = hold_wal_database_lock(&graph_db);
25915
25916        let err = match commands::infra::graph_db_snapshot_export_report(
25917            dir.path(),
25918            None,
25919            &dir.path().join("graph.db.gz"),
25920            false,
25921        ) {
25922            Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
25923            Err(err) => err,
25924        };
25925
25926        // The resilient open succeeds via the recovery fallback in this WAL-lock
25927        // case, so the live-lock signal surfaces at the recovery gate. Its
25928        // wording is now unified with the `map_live_lock` open-path diagnostic so
25929        // the operator gets the same actionable guidance regardless of which gate
25930        // trips (#tsreviewcleanup).
25931        let message = err.to_string();
25932        assert!(
25933            message.contains("recovered path")
25934                && message.contains("database is locked")
25935                && message.contains("wait for it to finish before retrying the export"),
25936            "expected unified live-lock recovery diagnostic, got {err:#}"
25937        );
25938    }
25939
25940    #[test]
25941    fn graph_db_snapshot_clean_export_maps_database_locked_to_live_lock_diagnostic() {
25942        let dir = setup_traversal_project();
25943        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25944        let graph_db = dir.path().join(".tsift/graph.db");
25945
25946        // Hold a plain EXCLUSIVE lock with no recovery sidecar so the export
25947        // clears the recovery fail-closed gate and reaches VACUUM INTO, which
25948        // then fails with a raw SQLite "database is locked".
25949        let blocker = Connection::open(&graph_db).unwrap();
25950        blocker
25951            .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
25952            .unwrap();
25953        assert!(!substrate::rollback_journal_path(&graph_db).exists());
25954
25955        let clean_path = dir.path().join("graph-clean-export.db");
25956        let err = match commands::infra::graph_db_snapshot_clean_export_copy(&graph_db, &clean_path)
25957        {
25958            Ok(bytes) => panic!("expected export to fail under live lock, got {bytes} bytes"),
25959            Err(err) => err,
25960        };
25961
25962        let message = err.to_string();
25963        assert!(
25964            message.contains("concurrent graph-db refresh or snapshot-import is in progress"),
25965            "expected actionable live-lock diagnostic, got {err:#}"
25966        );
25967        assert!(
25968            message.contains("wait for it to finish before retrying"),
25969            "expected retry guidance, got {err:#}"
25970        );
25971        // The raw SQLite phrasing must not leak as the surfaced top-level error.
25972        assert!(
25973            !message.contains("creating clean graph-db export copy"),
25974            "live-lock case must not surface the generic VACUUM context, got {err:#}"
25975        );
25976
25977        drop(blocker);
25978    }
25979
25980    #[test]
25981    fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
25982        let dir = setup_traversal_project();
25983        let session = dir.path().join("tasks/software/tsift.md");
25984        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25985        let graph_db = dir.path().join(".tsift/graph.db");
25986        let _lock = hold_rollback_journal_lock(&graph_db);
25987
25988        let result = cmd_graph_db(
25989            &session,
25990            None,
25991            GraphDbBackend::Sqlite,
25992            None,
25993            GraphDbQuery::Evidence {
25994                target: "kgnv".to_string(),
25995                depth: 3,
25996                limit: 8,
25997                cursor: None,
25998            },
25999            OutputFormat {
26000                json_output: false,
26001                compact: true,
26002                pretty: false,
26003                terse: false,
26004                ultra_terse: false,
26005                schema: false,
26006                envelope: false,
26007            },
26008        );
26009
26010        assert!(result.is_ok());
26011    }
26012
26013    fn current_graph_db_freshness() -> GraphDbFreshnessReport {
26014        GraphDbFreshnessReport {
26015            status: "current".to_string(),
26016            fail_closed: false,
26017            projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
26018            content_hash: Some("fixture".to_string()),
26019            source_watermark: None,
26020            diagnostics: Vec::new(),
26021        }
26022    }
26023
26024    #[test]
26025    fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
26026        let dir = setup_traversal_project();
26027        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26028        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
26029        let stale = GraphDbFreshnessReport {
26030            status: "stale".to_string(),
26031            fail_closed: true,
26032            projection_version: Some("old-v0".to_string()),
26033            content_hash: None,
26034            source_watermark: None,
26035            diagnostics: vec!["projection content hash is missing".to_string()],
26036        };
26037
26038        let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
26039            root: dir.path(),
26040            scope: None,
26041            backend: "sqlite",
26042            target: "kgnv",
26043            preferred_path: None,
26044            depth: 3,
26045            limit: 8,
26046            cursor: None,
26047            store: &store,
26048            freshness: stale,
26049            warnings: Vec::new(),
26050        }) {
26051            Ok(_) => panic!("stale graph freshness should fail closed"),
26052            Err(err) => err,
26053        };
26054        let message = err.to_string();
26055        assert!(message.contains("failed closed"), "{message}");
26056        assert!(message.contains("graph-db --path"), "{message}");
26057        assert!(message.contains("refresh --json"), "{message}");
26058    }
26059
26060    fn paged_graph_ids(
26061        store: &impl GraphStore,
26062        cursor: Option<&str>,
26063    ) -> (Vec<String>, GraphDbPageReport) {
26064        let report = graph_db_report_from_store(
26065            Path::new("."),
26066            None,
26067            "fixture",
26068            GraphDbQuery::Kind {
26069                kind: "backlog".to_string(),
26070                cursor: cursor.map(str::to_string),
26071                limit: Some(2),
26072                property_filters: vec!["phase=open".to_string()],
26073            },
26074            store,
26075            current_graph_db_freshness(),
26076            Vec::new(),
26077        )
26078        .unwrap();
26079        (
26080            report.nodes.iter().map(|node| node.id.clone()).collect(),
26081            report.page.unwrap(),
26082        )
26083    }
26084
26085    #[test]
26086    fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
26087        let nodes = (0..5)
26088            .map(|idx| {
26089                let phase = if idx == 1 { "closed" } else { "open" };
26090                SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
26091                    .with_property("phase", phase)
26092            })
26093            .collect::<Vec<_>>();
26094        let projection = GraphProjection {
26095            nodes,
26096            edges: Vec::new(),
26097        };
26098        let sqlite = SqliteGraphStore::in_memory().unwrap();
26099        projection.upsert_into(&sqlite).unwrap();
26100        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
26101        projection.upsert_into(&convex).unwrap();
26102
26103        let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
26104        let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
26105        assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
26106        assert_eq!(sqlite_first_ids, convex_first_ids);
26107        assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
26108        assert!(sqlite_first_page.truncated);
26109        assert_eq!(
26110            sqlite_first_page.returned_nodes,
26111            convex_first_page.returned_nodes
26112        );
26113        assert_eq!(
26114            sqlite_first_page.property_filters,
26115            convex_first_page.property_filters
26116        );
26117        assert!(
26118            sqlite_first_page
26119                .diagnostics
26120                .iter()
26121                .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
26122            "expected SQLite kind query plan diagnostics, got {:?}",
26123            sqlite_first_page.diagnostics
26124        );
26125
26126        let cursor = sqlite_first_page.next_cursor.as_deref();
26127        let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
26128        let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
26129        assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
26130        assert_eq!(sqlite_next_ids, convex_next_ids);
26131        assert_eq!(sqlite_next_page.next_cursor, None);
26132        assert!(!sqlite_next_page.truncated);
26133        assert_eq!(
26134            sqlite_next_page.returned_nodes,
26135            convex_next_page.returned_nodes
26136        );
26137        assert_eq!(
26138            sqlite_next_page.property_filters,
26139            convex_next_page.property_filters
26140        );
26141    }
26142
26143    #[test]
26144    fn traversal_shortest_path_crosses_artifacts_and_symbols() {
26145        let dir = setup_traversal_project();
26146        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26147        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
26148        let main = resolve_traversal_node(&graph, "main").unwrap();
26149
26150        let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
26151        assert_eq!(path.first(), Some(&backlog.handle));
26152        assert_eq!(path.last(), Some(&main.handle));
26153        assert!(
26154            path.len() >= 3,
26155            "expected backlog -> symbol -> main, got {path:?}"
26156        );
26157    }
26158
26159    #[test]
26160    fn traversal_report_recommends_next_bugfix_nodes() {
26161        let dir = setup_traversal_project();
26162        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26163        let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
26164
26165        assert_eq!(report.mode, "neighborhood");
26166        assert!(
26167            report
26168                .recommendations
26169                .iter()
26170                .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
26171            "expected helper recommendation, got {:?}",
26172            report.recommendations
26173        );
26174        assert!(
26175            !report.exploration.source_windows.is_empty(),
26176            "expected exploration source windows"
26177        );
26178        assert!(
26179            report
26180                .exploration
26181                .no_reread_guidance
26182                .contains("avoid whole-file reads")
26183        );
26184    }
26185
26186    #[test]
26187    fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
26188        let dir = setup_traversal_project();
26189        std::thread::sleep(std::time::Duration::from_millis(50));
26190        std::fs::write(
26191            dir.path().join("main.rs"),
26192            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26193        )
26194        .unwrap();
26195
26196        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26197
26198        assert!(
26199            graph
26200                .warnings
26201                .iter()
26202                .any(|warning| warning.contains("index refreshed")
26203                    && warning.contains("graph traversal packet")),
26204            "expected refresh diagnostic, got {:?}",
26205            graph.warnings
26206        );
26207        assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
26208
26209        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26210        let summary = db.compute_changes(dir.path()).unwrap();
26211        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26212    }
26213
26214    #[test]
26215    fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
26216        let dir = setup_traversal_project();
26217        let db_path = dir.path().join(".tsift/index.db");
26218        let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
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        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
26228
26229        assert!(
26230            graph
26231                .warnings
26232                .iter()
26233                .any(|warning| warning.contains("falling back to raw source file nodes")),
26234            "expected raw-source fallback diagnostic, got {:?}",
26235            graph.warnings
26236        );
26237        assert!(
26238            file.detail
26239                .as_deref()
26240                .is_some_and(|detail| detail.contains("raw source fallback")),
26241            "expected raw-source detail, got {:?}",
26242            file.detail
26243        );
26244        assert!(
26245            file.expand.contains("source-read"),
26246            "expected source-read fallback command, got {}",
26247            file.expand
26248        );
26249        assert!(
26250            resolve_traversal_node(&graph, "helper").is_none(),
26251            "stale symbol evidence should be skipped when refresh is blocked"
26252        );
26253    }
26254
26255    #[test]
26256    fn traversal_cmd_supports_json_and_html_outputs() {
26257        let dir = setup_traversal_project();
26258        cmd_traverse(
26259            Some("#kgnv"),
26260            Some("main"),
26261            dir.path(),
26262            None,
26263            1,
26264            50,
26265            TraverseFormat::Json,
26266            false,
26267            false,
26268            false,
26269            None,
26270        )
26271        .unwrap();
26272        cmd_traverse(
26273            None,
26274            None,
26275            dir.path(),
26276            None,
26277            1,
26278            50,
26279            TraverseFormat::Html,
26280            false,
26281            false,
26282            false,
26283            None,
26284        )
26285        .unwrap();
26286    }
26287
26288    #[test]
26289    fn traversal_html_renders_inline_graph_visualization() {
26290        let dir = setup_traversal_project();
26291        seed_traversal_semantic_summaries(dir.path());
26292        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26293        let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
26294        let html = traversal_report_html(&report).unwrap();
26295
26296        assert!(html.contains("id=\"graph-canvas\""));
26297        assert!(html.contains("semantic_concept"));
26298        assert!(html.contains("graph navigation"));
26299        assert!(html.contains("JSON.parse"));
26300    }
26301
26302    #[test]
26303    fn compact_helpers_trim_scores_and_snippets() {
26304        assert_eq!(format_score(0.12345, true), "0.12");
26305        assert_eq!(format_score(0.12345, false), "0.1235");
26306        let snippet = compact_snippet("    first line with useful context\nsecond");
26307        assert_eq!(snippet.as_deref(), Some("first line with useful context"));
26308    }
26309
26310    #[test]
26311    fn compact_members_caps_list() {
26312        let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
26313            .iter()
26314            .map(|n| graph::CommunityMember::new(*n))
26315            .collect();
26316        assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
26317    }
26318
26319    #[test]
26320    fn abbreviate_kind_maps_common_kinds() {
26321        assert_eq!(abbreviate_kind("function"), "fn");
26322        assert_eq!(abbreviate_kind("method"), "meth");
26323        assert_eq!(abbreviate_kind("class"), "cls");
26324        assert_eq!(abbreviate_kind("interface"), "iface");
26325        assert_eq!(abbreviate_kind("type_alias"), "type");
26326        assert_eq!(abbreviate_kind("data_class"), "data_cls");
26327        assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
26328        assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
26329        assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
26330        assert_eq!(abbreviate_kind("object"), "obj");
26331        assert_eq!(abbreviate_kind("heading"), "h");
26332        assert_eq!(abbreviate_kind("code_block"), "code");
26333        // short kinds pass through
26334        assert_eq!(abbreviate_kind("struct"), "struct");
26335        assert_eq!(abbreviate_kind("trait"), "trait");
26336        assert_eq!(abbreviate_kind("enum"), "enum");
26337        assert_eq!(abbreviate_kind("const"), "const");
26338        assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
26339    }
26340
26341    #[test]
26342    fn abbreviate_match_type_maps_search_types() {
26343        assert_eq!(abbreviate_match_type("exact_name"), "exact");
26344        assert_eq!(abbreviate_match_type("partial_tags"), "partial");
26345        assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
26346        assert_eq!(abbreviate_match_type("other_type"), "other_type");
26347    }
26348
26349    #[test]
26350    fn explain_compact_groups_edges_by_file() {
26351        let edges = vec![
26352            index::StoredEdge {
26353                caller_file: "src/main.rs".to_string(),
26354                caller_name: "main".to_string(),
26355                caller_line: 1,
26356                callee_name: "helper".to_string(),
26357                call_site_line: 2,
26358                tagpath_handle: None,
26359            },
26360            index::StoredEdge {
26361                caller_file: "src/main.rs".to_string(),
26362                caller_name: "main".to_string(),
26363                caller_line: 1,
26364                callee_name: "render".to_string(),
26365                call_site_line: 3,
26366                tagpath_handle: None,
26367            },
26368        ];
26369        let lines = format_edge_groups(&edges, false);
26370        assert_eq!(lines, vec!["  src/main.rs (2): helper, render"]);
26371    }
26372
26373    #[test]
26374    fn search_hit_groups_preserve_file_counts_and_samples() {
26375        let dir = tempfile::tempdir().unwrap();
26376        let root = dir.path();
26377        let main_rs = root.join("src/main.rs");
26378        fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
26379        fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
26380        let freshness = exact_search_file_timestamp(&main_rs);
26381        let hits = vec![
26382            sift::SearchHit {
26383                artifact_id: "a".to_string(),
26384                artifact_kind: sift::ContextArtifactKind::File,
26385                path: main_rs.display().to_string(),
26386                rank: 1,
26387                score: 10.0,
26388                confidence: sift::ScoreConfidence::High,
26389                location: Some("line 3".to_string()),
26390                snippet: "claudescore-3 anchor".to_string(),
26391                provenance: sift::ArtifactProvenance {
26392                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26393                    source: "ripgrep -F".to_string(),
26394                    synthetic: false,
26395                },
26396                freshness: freshness.clone(),
26397                budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
26398            },
26399            sift::SearchHit {
26400                artifact_id: "b".to_string(),
26401                artifact_kind: sift::ContextArtifactKind::File,
26402                path: main_rs.display().to_string(),
26403                rank: 2,
26404                score: 9.0,
26405                confidence: sift::ScoreConfidence::High,
26406                location: Some("line 7".to_string()),
26407                snippet: "claudescore-3 follow-up".to_string(),
26408                provenance: sift::ArtifactProvenance {
26409                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26410                    source: "ripgrep -F".to_string(),
26411                    synthetic: false,
26412                },
26413                freshness: freshness.clone(),
26414                budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
26415            },
26416            sift::SearchHit {
26417                artifact_id: "c".to_string(),
26418                artifact_kind: sift::ContextArtifactKind::File,
26419                path: main_rs.display().to_string(),
26420                rank: 3,
26421                score: 8.0,
26422                confidence: sift::ScoreConfidence::High,
26423                location: Some("line 9".to_string()),
26424                snippet: "claudescore-3 tail".to_string(),
26425                provenance: sift::ArtifactProvenance {
26426                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26427                    source: "ripgrep -F".to_string(),
26428                    synthetic: false,
26429                },
26430                freshness,
26431                budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
26432            },
26433        ];
26434
26435        let groups = group_search_hits(&hits, root, false);
26436        assert_eq!(groups.len(), 1);
26437        assert_eq!(groups[0].path, "src/main.rs");
26438        assert_eq!(groups[0].hits, 3);
26439        assert_eq!(
26440            groups[0].samples,
26441            vec![
26442                "line 3: claudescore-3 anchor".to_string(),
26443                "line 7: claudescore-3 follow-up".to_string()
26444            ]
26445        );
26446        assert!(should_collapse_search_hits(&hits, root, false));
26447    }
26448
26449    #[test]
26450    fn dense_edge_groups_trigger_collapse() {
26451        let edges = vec![
26452            index::StoredEdge {
26453                caller_file: "src/main.rs".to_string(),
26454                caller_name: "main".to_string(),
26455                caller_line: 1,
26456                callee_name: "helper".to_string(),
26457                call_site_line: 2,
26458                tagpath_handle: None,
26459            },
26460            index::StoredEdge {
26461                caller_file: "src/main.rs".to_string(),
26462                caller_name: "beta".to_string(),
26463                caller_line: 5,
26464                callee_name: "helper".to_string(),
26465                call_site_line: 6,
26466                tagpath_handle: None,
26467            },
26468            index::StoredEdge {
26469                caller_file: "src/main.rs".to_string(),
26470                caller_name: "gamma".to_string(),
26471                caller_line: 9,
26472                callee_name: "helper".to_string(),
26473                call_site_line: 10,
26474                tagpath_handle: None,
26475            },
26476        ];
26477        assert!(should_collapse_edge_groups(&edges));
26478    }
26479
26480    // --- workspace indexing ---
26481
26482    fn setup_workspace() -> tempfile::TempDir {
26483        let dir = tempfile::tempdir().unwrap();
26484        let root = dir.path();
26485        std::fs::write(
26486            root.join(".gitmodules"),
26487            r#"[submodule "src/alpha"]
26488	path = src/alpha
26489	url = https://example.com/alpha
26490[submodule "src/beta"]
26491	path = src/beta
26492	url = https://example.com/beta
26493"#,
26494        )
26495        .unwrap();
26496        let alpha = root.join("src/alpha");
26497        let beta = root.join("src/beta");
26498        std::fs::create_dir_all(&alpha).unwrap();
26499        std::fs::create_dir_all(&beta).unwrap();
26500        std::fs::write(
26501            alpha.join("lib.rs"),
26502            "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
26503        )
26504        .unwrap();
26505        std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
26506        dir
26507    }
26508
26509    fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
26510        let dir = tempfile::tempdir().unwrap();
26511        let root = dir.path();
26512        std::fs::write(
26513            root.join(".gitmodules"),
26514            r#"[submodule "pkg/app/foo"]
26515	path = pkg/app/foo
26516	url = https://example.com/pkg-app-foo
26517[submodule "vendor/foo"]
26518	path = vendor/foo
26519	url = https://example.com/vendor-foo
26520"#,
26521        )
26522        .unwrap();
26523        let pkg_foo = root.join("pkg/app/foo");
26524        let vendor_foo = root.join("vendor/foo");
26525        std::fs::create_dir_all(&pkg_foo).unwrap();
26526        std::fs::create_dir_all(&vendor_foo).unwrap();
26527        std::fs::write(
26528            pkg_foo.join("lib.rs"),
26529            "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
26530        )
26531        .unwrap();
26532        std::fs::write(
26533            vendor_foo.join("lib.rs"),
26534            "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
26535        )
26536        .unwrap();
26537        dir
26538    }
26539
26540    #[test]
26541    fn workspace_index_creates_per_submodule_dbs() {
26542        let dir = setup_workspace();
26543        cmd_index(
26544            dir.path(),
26545            false,
26546            false,
26547            false,
26548            false,
26549            false,
26550            true,
26551            None,
26552            false,
26553            false,
26554            false,
26555            false,
26556            false,
26557            false,
26558        )
26559        .unwrap();
26560        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26561        assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
26562    }
26563
26564    #[test]
26565    fn workspace_index_single_submodule() {
26566        let dir = setup_workspace();
26567        cmd_index(
26568            dir.path(),
26569            false,
26570            false,
26571            false,
26572            false,
26573            false,
26574            false,
26575            Some("alpha"),
26576            false,
26577            false,
26578            false,
26579            false,
26580            false,
26581            false,
26582        )
26583        .unwrap();
26584        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26585        assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
26586    }
26587
26588    #[test]
26589    fn workspace_index_single_submodule_errors_on_unknown_scope() {
26590        let dir = setup_workspace();
26591
26592        let err = cmd_index(
26593            dir.path(),
26594            false,
26595            false,
26596            false,
26597            false,
26598            false,
26599            false,
26600            Some("missing"),
26601            false,
26602            false,
26603            false,
26604            false,
26605            false,
26606            false,
26607        )
26608        .unwrap_err();
26609
26610        let msg = err.to_string();
26611        assert!(msg.contains("unknown scope `missing`"));
26612        assert!(msg.contains("Available scopes: alpha, beta"));
26613        assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
26614    }
26615
26616    #[test]
26617    fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
26618        let dir = setup_workspace_with_duplicate_leaf_names();
26619        cmd_index(
26620            dir.path(),
26621            false,
26622            false,
26623            false,
26624            false,
26625            false,
26626            true,
26627            None,
26628            false,
26629            false,
26630            false,
26631            false,
26632            false,
26633            false,
26634        )
26635        .unwrap();
26636
26637        assert!(
26638            dir.path()
26639                .join(".tsift/indexes/pkg/app/foo/index.db")
26640                .exists()
26641        );
26642        assert!(
26643            dir.path()
26644                .join(".tsift/indexes/vendor/foo/index.db")
26645                .exists()
26646        );
26647    }
26648
26649    #[test]
26650    fn federated_search_across_submodules() {
26651        let dir = setup_workspace();
26652        cmd_index(
26653            dir.path(),
26654            false,
26655            false,
26656            false,
26657            false,
26658            false,
26659            true,
26660            None,
26661            false,
26662            false,
26663            false,
26664            false,
26665            false,
26666            false,
26667        )
26668        .unwrap();
26669        let (hits, _diag) = federated_symbol_search(
26670            dir.path(),
26671            "alpha_helper",
26672            10,
26673            &TagpathSearchOpts {
26674                no_tagpath: true,
26675                strict: false,
26676            },
26677        )
26678        .unwrap();
26679        assert!(
26680            !hits.is_empty(),
26681            "should find alpha_helper via federated search"
26682        );
26683    }
26684
26685    #[test]
26686    fn federated_search_respects_isolation() {
26687        let dir = setup_workspace();
26688        let tsift_dir = dir.path().join(".tsift");
26689        std::fs::create_dir_all(&tsift_dir).unwrap();
26690        std::fs::write(
26691            tsift_dir.join("config.toml"),
26692            r#"
26693[overrides.alpha]
26694tier = "isolated"
26695"#,
26696        )
26697        .unwrap();
26698        cmd_index(
26699            dir.path(),
26700            false,
26701            false,
26702            false,
26703            false,
26704            false,
26705            true,
26706            None,
26707            false,
26708            false,
26709            false,
26710            false,
26711            false,
26712            false,
26713        )
26714        .unwrap();
26715        let (hits, _diag) = federated_symbol_search(
26716            dir.path(),
26717            "alpha_helper",
26718            10,
26719            &TagpathSearchOpts {
26720                no_tagpath: true,
26721                strict: false,
26722            },
26723        )
26724        .unwrap();
26725        assert!(
26726            hits.is_empty(),
26727            "isolated submodule should not appear in federated search"
26728        );
26729    }
26730
26731    #[test]
26732    fn federated_lexical_search_respects_isolation() {
26733        let dir = setup_workspace();
26734        let tsift_dir = dir.path().join(".tsift");
26735        std::fs::create_dir_all(&tsift_dir).unwrap();
26736        std::fs::write(
26737            tsift_dir.join("config.toml"),
26738            r#"
26739[overrides.alpha]
26740tier = "isolated"
26741"#,
26742        )
26743        .unwrap();
26744        cmd_index(
26745            dir.path(),
26746            false,
26747            false,
26748            false,
26749            false,
26750            false,
26751            true,
26752            None,
26753            false,
26754            false,
26755            false,
26756            false,
26757            false,
26758            false,
26759        )
26760        .unwrap();
26761
26762        let response = federated_sift_search(
26763            dir.path(),
26764            &dir.path().join(".tsift/search-cache"),
26765            "fn",
26766            10,
26767            0,
26768            "lexical",
26769            None,
26770        )
26771        .unwrap();
26772
26773        assert!(
26774            !response.hits.is_empty(),
26775            "shared scopes should still contribute lexical hits"
26776        );
26777        assert!(
26778            response
26779                .hits
26780                .iter()
26781                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26782            "isolated scope should not leak lexical hits: {:?}",
26783            response.hits
26784        );
26785    }
26786
26787    #[test]
26788    fn federated_lexical_search_respects_private_tier() {
26789        let dir = setup_workspace();
26790        let tsift_dir = dir.path().join(".tsift");
26791        std::fs::create_dir_all(&tsift_dir).unwrap();
26792        std::fs::write(
26793            tsift_dir.join("config.toml"),
26794            r#"
26795[overrides.alpha]
26796tier = "private"
26797"#,
26798        )
26799        .unwrap();
26800        cmd_index(
26801            dir.path(),
26802            false,
26803            false,
26804            false,
26805            false,
26806            false,
26807            true,
26808            None,
26809            false,
26810            false,
26811            false,
26812            false,
26813            false,
26814            false,
26815        )
26816        .unwrap();
26817
26818        let response = federated_sift_search(
26819            dir.path(),
26820            &dir.path().join(".tsift/search-cache"),
26821            "fn",
26822            10,
26823            0,
26824            "lexical",
26825            None,
26826        )
26827        .unwrap();
26828
26829        assert!(
26830            !response.hits.is_empty(),
26831            "shared scopes should still contribute lexical hits"
26832        );
26833        assert!(
26834            response
26835                .hits
26836                .iter()
26837                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26838            "private scope should not leak lexical hits: {:?}",
26839            response.hits
26840        );
26841    }
26842
26843    #[test]
26844    fn scoped_search_finds_submodule_symbols() {
26845        let dir = setup_workspace();
26846        cmd_index(
26847            dir.path(),
26848            false,
26849            false,
26850            false,
26851            false,
26852            false,
26853            true,
26854            None,
26855            false,
26856            false,
26857            false,
26858            false,
26859            false,
26860            false,
26861        )
26862        .unwrap();
26863        let cfg = config::Config::load(dir.path()).unwrap();
26864        let db_path = cfg.db_path_for(dir.path(), "alpha");
26865        let db = index::IndexDb::open(&db_path).unwrap();
26866        let hits = db.symbol_search("alpha_main", 10).unwrap();
26867        assert!(!hits.is_empty());
26868        assert_eq!(hits[0].name, "alpha_main");
26869    }
26870
26871    #[test]
26872    fn scoped_search_cmd_errors_on_unknown_scope() {
26873        let dir = setup_workspace();
26874
26875        let err = cmd_search(
26876            "alpha_main".to_string(),
26877            Some(dir.path().to_path_buf()),
26878            5,
26879            Some("lexical".to_string()),
26880            Some("missing".to_string()),
26881            false,
26882            false,
26883            false,
26884            0,
26885            false,
26886            false,
26887            false,
26888            false,
26889            false,
26890            false,
26891            false,
26892        )
26893        .unwrap_err();
26894
26895        let msg = err.to_string();
26896        assert!(msg.contains("unknown scope `missing`"));
26897        assert!(msg.contains("Available scopes: alpha, beta"));
26898    }
26899
26900    #[test]
26901    fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
26902        let dir = setup_workspace_with_duplicate_leaf_names();
26903        cmd_index(
26904            dir.path(),
26905            false,
26906            false,
26907            false,
26908            false,
26909            false,
26910            true,
26911            None,
26912            false,
26913            false,
26914            false,
26915            false,
26916            false,
26917            false,
26918        )
26919        .unwrap();
26920
26921        let err = cmd_search(
26922            "vendor_only".to_string(),
26923            Some(dir.path().to_path_buf()),
26924            5,
26925            Some("lexical".to_string()),
26926            Some("foo".to_string()),
26927            false,
26928            false,
26929            false,
26930            0,
26931            false,
26932            false,
26933            false,
26934            false,
26935            false,
26936            false,
26937            false,
26938        )
26939        .unwrap_err();
26940
26941        let msg = err.to_string();
26942        assert!(msg.contains("ambiguous scope `foo`"));
26943        assert!(msg.contains("pkg/app/foo"));
26944        assert!(msg.contains("vendor/foo"));
26945    }
26946
26947    #[test]
26948    fn scoped_graph_query() {
26949        let dir = setup_workspace();
26950        cmd_index(
26951            dir.path(),
26952            false,
26953            false,
26954            false,
26955            false,
26956            false,
26957            true,
26958            None,
26959            false,
26960            false,
26961            false,
26962            false,
26963            false,
26964            false,
26965        )
26966        .unwrap();
26967        let cfg = config::Config::load(dir.path()).unwrap();
26968        let db_path = cfg.db_path_for(dir.path(), "alpha");
26969        let db = index::IndexDb::open(&db_path).unwrap();
26970        let callees = db.callees_of("alpha_main").unwrap();
26971        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
26972        assert!(names.contains(&"alpha_helper"));
26973    }
26974
26975    fn assert_workspace_query_requires_scope(err: anyhow::Error) {
26976        let msg = err.to_string();
26977        assert!(msg.contains("require `--scope <scope>`"), "{msg}");
26978        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26979        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26980        assert!(
26981            !msg.contains("no index found at"),
26982            "workspace query should fail with scope guidance, got: {msg}"
26983        );
26984    }
26985
26986    fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
26987        let msg = err.to_string();
26988        assert!(
26989            msg.contains("requires `--scope <scope>` or `--federated`"),
26990            "{msg}"
26991        );
26992        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26993        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26994        assert!(
26995            !msg.contains("autoindexing index"),
26996            "workspace search should fail before creating a shared root index: {msg}"
26997        );
26998    }
26999
27000    #[test]
27001    fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
27002        let dir = setup_workspace();
27003        cmd_index(
27004            dir.path(),
27005            false,
27006            false,
27007            false,
27008            false,
27009            false,
27010            true,
27011            None,
27012            false,
27013            false,
27014            false,
27015            false,
27016            false,
27017            false,
27018        )
27019        .unwrap();
27020
27021        let err = cmd_graph(
27022            "alpha_main",
27023            dir.path(),
27024            false,
27025            false,
27026            None,
27027            20,
27028            false,
27029            false,
27030            false,
27031            false,
27032            false,
27033            false,
27034            false,
27035            TagpathSearchOpts::default(),
27036        )
27037        .unwrap_err();
27038
27039        assert_workspace_query_requires_scope(err);
27040    }
27041
27042    #[test]
27043    fn graph_cmd_infers_scope_from_nested_workspace_path() {
27044        let dir = setup_workspace();
27045        cmd_index(
27046            dir.path(),
27047            false,
27048            false,
27049            false,
27050            false,
27051            false,
27052            true,
27053            None,
27054            false,
27055            false,
27056            false,
27057            false,
27058            false,
27059            false,
27060        )
27061        .unwrap();
27062        let nested = dir.path().join("src/alpha/nested");
27063        std::fs::create_dir_all(&nested).unwrap();
27064
27065        let result = cmd_graph(
27066            "alpha_main",
27067            &nested,
27068            false,
27069            false,
27070            None,
27071            20,
27072            false,
27073            false,
27074            false,
27075            false,
27076            false,
27077            false,
27078            false,
27079            TagpathSearchOpts::default(),
27080        );
27081
27082        assert!(result.is_ok());
27083    }
27084
27085    #[test]
27086    fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
27087        let dir = setup_workspace();
27088        cmd_index(
27089            dir.path(),
27090            false,
27091            false,
27092            false,
27093            false,
27094            false,
27095            true,
27096            None,
27097            false,
27098            false,
27099            false,
27100            false,
27101            false,
27102            false,
27103        )
27104        .unwrap();
27105
27106        let err = cmd_communities(
27107            dir.path(),
27108            None,
27109            1,
27110            10,
27111            false,
27112            false,
27113            false,
27114            false,
27115            false,
27116            false,
27117            TagpathSearchOpts::default(),
27118        )
27119        .unwrap_err();
27120
27121        assert_workspace_query_requires_scope(err);
27122    }
27123
27124    #[test]
27125    fn communities_cmd_infers_scope_from_nested_workspace_path() {
27126        let dir = setup_workspace();
27127        cmd_index(
27128            dir.path(),
27129            false,
27130            false,
27131            false,
27132            false,
27133            false,
27134            true,
27135            None,
27136            false,
27137            false,
27138            false,
27139            false,
27140            false,
27141            false,
27142        )
27143        .unwrap();
27144        let nested = dir.path().join("src/alpha/nested");
27145        std::fs::create_dir_all(&nested).unwrap();
27146
27147        let result = cmd_communities(
27148            &nested,
27149            None,
27150            1,
27151            10,
27152            false,
27153            false,
27154            false,
27155            false,
27156            false,
27157            false,
27158            TagpathSearchOpts::default(),
27159        );
27160
27161        assert!(result.is_ok());
27162    }
27163
27164    #[test]
27165    fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
27166        let dir = setup_workspace();
27167        cmd_index(
27168            dir.path(),
27169            false,
27170            false,
27171            false,
27172            false,
27173            false,
27174            true,
27175            None,
27176            false,
27177            false,
27178            false,
27179            false,
27180            false,
27181            false,
27182        )
27183        .unwrap();
27184
27185        let err = cmd_path(
27186            "alpha_main",
27187            "alpha_helper",
27188            dir.path(),
27189            None,
27190            false,
27191            false,
27192            false,
27193            false,
27194            false,
27195            TagpathSearchOpts::default(),
27196        )
27197        .unwrap_err();
27198
27199        assert_workspace_query_requires_scope(err);
27200    }
27201
27202    #[test]
27203    fn path_cmd_infers_scope_from_nested_workspace_path() {
27204        let dir = setup_workspace();
27205        cmd_index(
27206            dir.path(),
27207            false,
27208            false,
27209            false,
27210            false,
27211            false,
27212            true,
27213            None,
27214            false,
27215            false,
27216            false,
27217            false,
27218            false,
27219            false,
27220        )
27221        .unwrap();
27222        let nested = dir.path().join("src/alpha/nested");
27223        std::fs::create_dir_all(&nested).unwrap();
27224
27225        let result = cmd_path(
27226            "alpha_main",
27227            "alpha_helper",
27228            &nested,
27229            None,
27230            false,
27231            false,
27232            false,
27233            false,
27234            false,
27235            TagpathSearchOpts::default(),
27236        );
27237
27238        assert!(result.is_ok());
27239    }
27240
27241    #[test]
27242    fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27243        let dir = setup_graph_index();
27244        let db_path = dir.path().join(".tsift/index.db");
27245        let _lock = hold_rollback_journal_lock(&db_path);
27246
27247        let result = cmd_path(
27248            "main",
27249            "helper",
27250            dir.path(),
27251            None,
27252            false,
27253            false,
27254            false,
27255            false,
27256            false,
27257            TagpathSearchOpts::default(),
27258        );
27259
27260        assert!(result.is_ok());
27261    }
27262
27263    #[test]
27264    fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
27265        let dir = setup_workspace();
27266        cmd_index(
27267            dir.path(),
27268            false,
27269            false,
27270            false,
27271            false,
27272            false,
27273            true,
27274            None,
27275            false,
27276            false,
27277            false,
27278            false,
27279            false,
27280            false,
27281        )
27282        .unwrap();
27283
27284        let err = cmd_explain(
27285            "alpha_main",
27286            dir.path(),
27287            None,
27288            15,
27289            false,
27290            false,
27291            false,
27292            false,
27293            false,
27294            false,
27295            false,
27296            false,
27297        )
27298        .unwrap_err();
27299
27300        assert_workspace_query_requires_scope(err);
27301    }
27302
27303    #[test]
27304    fn explain_cmd_infers_scope_from_nested_workspace_path() {
27305        let dir = setup_workspace();
27306        cmd_index(
27307            dir.path(),
27308            false,
27309            false,
27310            false,
27311            false,
27312            false,
27313            true,
27314            None,
27315            false,
27316            false,
27317            false,
27318            false,
27319            false,
27320            false,
27321        )
27322        .unwrap();
27323        let nested = dir.path().join("src/alpha/nested");
27324        std::fs::create_dir_all(&nested).unwrap();
27325
27326        let result = cmd_explain(
27327            "alpha_main",
27328            &nested,
27329            None,
27330            15,
27331            false,
27332            false,
27333            false,
27334            false,
27335            false,
27336            false,
27337            false,
27338            false,
27339        );
27340
27341        assert!(result.is_ok());
27342    }
27343
27344    #[test]
27345    fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27346        let dir = setup_graph_index();
27347        let db_path = dir.path().join(".tsift/index.db");
27348        let _lock = hold_rollback_journal_lock(&db_path);
27349
27350        let result = cmd_explain(
27351            "main",
27352            dir.path(),
27353            None,
27354            15,
27355            false,
27356            false,
27357            false,
27358            false,
27359            false,
27360            false,
27361            false,
27362            false,
27363        );
27364
27365        assert!(result.is_ok());
27366    }
27367
27368    // --- community detection ---
27369
27370    #[test]
27371    fn community_detection_groups_related() {
27372        let dir = setup_graph_index();
27373        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27374        let edges = db.all_edges().unwrap();
27375        let result = graph::detect_communities(&edges);
27376        assert!(result.node_count > 0);
27377        assert!(!result.communities.is_empty());
27378    }
27379
27380    #[test]
27381    fn community_cmd_autoindexes_missing_index_by_default() {
27382        let dir = tempfile::tempdir().unwrap();
27383        let result = cmd_communities(
27384            dir.path(),
27385            None,
27386            2,
27387            10,
27388            false,
27389            false,
27390            false,
27391            false,
27392            false,
27393            false,
27394            TagpathSearchOpts::default(),
27395        );
27396
27397        assert!(result.is_ok());
27398        assert!(dir.path().join(".tsift/index.db").exists());
27399    }
27400
27401    // --- path ---
27402
27403    #[test]
27404    fn path_finds_connected_symbols() {
27405        let dir = setup_graph_index();
27406        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27407        let edges = db.all_edges().unwrap();
27408        let result = graph::shortest_path(&edges, "main", "helper");
27409        assert!(result.is_some());
27410        let path = result.unwrap();
27411        assert_eq!(path.hops, 1);
27412    }
27413
27414    #[test]
27415    fn path_returns_none_for_unknown() {
27416        let dir = setup_graph_index();
27417        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27418        let edges = db.all_edges().unwrap();
27419        assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
27420    }
27421
27422    #[test]
27423    fn path_cmd_autoindexes_missing_index_by_default() {
27424        let dir = tempfile::tempdir().unwrap();
27425        let result = cmd_path(
27426            "a",
27427            "b",
27428            dir.path(),
27429            None,
27430            false,
27431            false,
27432            false,
27433            false,
27434            false,
27435            TagpathSearchOpts::default(),
27436        );
27437
27438        assert!(result.is_ok());
27439        assert!(dir.path().join(".tsift/index.db").exists());
27440    }
27441
27442    // --- explain ---
27443
27444    #[test]
27445    fn explain_shows_symbol_info() {
27446        let dir = setup_graph_index();
27447        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27448        let symbols = db.symbol_info("main").unwrap();
27449        assert!(!symbols.is_empty());
27450        assert_eq!(symbols[0].name, "main");
27451        assert_eq!(symbols[0].kind, "function");
27452    }
27453
27454    #[test]
27455    fn explain_cmd_autoindexes_missing_index_by_default() {
27456        let dir = tempfile::tempdir().unwrap();
27457        let result = cmd_explain(
27458            "main",
27459            dir.path(),
27460            None,
27461            15,
27462            false,
27463            false,
27464            false,
27465            false,
27466            false,
27467            false,
27468            false,
27469            false,
27470        );
27471
27472        assert!(result.is_ok());
27473        assert!(dir.path().join(".tsift/index.db").exists());
27474    }
27475
27476    fn hold_write_lock(db_path: &std::path::Path) -> Connection {
27477        let conn = Connection::open(db_path).unwrap();
27478        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
27479        conn
27480    }
27481
27482    fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
27483        use fs4::fs_std::FileExt;
27484        use std::io::Write;
27485
27486        let mut file = std::fs::OpenOptions::new()
27487            .read(true)
27488            .write(true)
27489            .create(true)
27490            .truncate(false)
27491            .open(lock_path)
27492            .unwrap();
27493        assert!(file.try_lock_exclusive().unwrap());
27494        writeln!(file, "{}", std::process::id()).unwrap();
27495        file
27496    }
27497
27498    fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
27499        let conn = Connection::open(db_path).unwrap();
27500        conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
27501            .unwrap();
27502        std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
27503        conn
27504    }
27505
27506    fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
27507        let conn = Connection::open(db_path).unwrap();
27508        conn.execute_batch(
27509            "PRAGMA journal_mode=WAL;
27510             PRAGMA wal_autocheckpoint=0;
27511             CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
27512             INSERT INTO wal_lock_probe DEFAULT VALUES;
27513             PRAGMA locking_mode=EXCLUSIVE;
27514             BEGIN EXCLUSIVE;",
27515        )
27516        .unwrap();
27517        assert!(substrate::wal_sidecar_path(db_path).exists());
27518        conn
27519    }
27520
27521    #[test]
27522    fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
27523        let dir = setup_graph_index();
27524        let db_path = dir.path().join(".tsift/index.db");
27525        let _lock = hold_wal_database_lock(&db_path);
27526
27527        let err = cmd_index(
27528            dir.path(),
27529            false,
27530            false,
27531            false,
27532            false,
27533            false,
27534            false,
27535            None,
27536            false,
27537            false,
27538            false,
27539            false,
27540            false,
27541            false,
27542        )
27543        .unwrap_err();
27544
27545        let msg = err.to_string();
27546        assert!(msg.contains("indexing"));
27547        assert!(msg.contains("lock diagnostics:"));
27548        assert!(msg.contains("lock: absent"));
27549        assert!(msg.contains("wal: present") || msg.contains("shm: present"));
27550        assert!(msg.contains("wedged writer holding live WAL sidecars"));
27551        assert!(msg.contains("snapshot fallback"));
27552    }
27553
27554    #[test]
27555    fn search_cmd_succeeds_while_writer_lock_is_held() {
27556        let dir = setup_graph_index();
27557        let db_path = dir.path().join(".tsift/index.db");
27558        let _lock = hold_write_lock(&db_path);
27559
27560        let result = cmd_search(
27561            "main".to_string(),
27562            Some(dir.path().to_path_buf()),
27563            5,
27564            Some("lexical".to_string()),
27565            None,
27566            false,
27567            false,
27568            false,
27569            0,
27570            true,
27571            false,
27572            false,
27573            false,
27574            false,
27575            false,
27576            false,
27577        );
27578
27579        assert!(result.is_ok());
27580    }
27581
27582    #[test]
27583    fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
27584        let dir = setup_graph_index();
27585        let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
27586
27587        let result = cmd_search(
27588            "main".to_string(),
27589            Some(dir.path().to_path_buf()),
27590            5,
27591            Some("lexical".to_string()),
27592            None,
27593            false,
27594            false,
27595            false,
27596            0,
27597            true,
27598            false,
27599            false,
27600            false,
27601            false,
27602            false,
27603            false,
27604        );
27605
27606        assert!(result.is_ok());
27607    }
27608
27609    #[test]
27610    fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
27611        let dir = setup_graph_index();
27612        let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
27613
27614        let result = cmd_search(
27615            "main".to_string(),
27616            Some(dir.path().to_path_buf()),
27617            5,
27618            Some("lexical".to_string()),
27619            None,
27620            false,
27621            false,
27622            false,
27623            0,
27624            true,
27625            false,
27626            false,
27627            false,
27628            false,
27629            false,
27630            false,
27631        );
27632
27633        assert!(result.is_ok());
27634    }
27635
27636    #[test]
27637    fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
27638        let dir = setup_graph_index();
27639        std::thread::sleep(std::time::Duration::from_millis(50));
27640        std::fs::write(
27641            dir.path().join("main.rs"),
27642            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27643        )
27644        .unwrap();
27645
27646        let err = cmd_search(
27647            "helper".to_string(),
27648            Some(dir.path().to_path_buf()),
27649            5,
27650            Some("lexical".to_string()),
27651            None,
27652            false,
27653            false,
27654            false,
27655            0,
27656            false,
27657            false,
27658            false,
27659            false,
27660            false,
27661            false,
27662            false,
27663        )
27664        .unwrap_err();
27665
27666        assert!(err.to_string().contains("search aborted"));
27667        assert!(err.to_string().contains("index is stale"));
27668        assert!(err.to_string().contains("--no-autoindex"));
27669    }
27670
27671    #[test]
27672    fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
27673        let dir = setup_graph_index();
27674        std::thread::sleep(std::time::Duration::from_millis(50));
27675        std::fs::write(
27676            dir.path().join("main.rs"),
27677            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27678        )
27679        .unwrap();
27680        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27681
27682        let err = cmd_search(
27683            "helper".to_string(),
27684            Some(dir.path().to_path_buf()),
27685            5,
27686            Some("lexical".to_string()),
27687            None,
27688            false,
27689            false,
27690            false,
27691            0,
27692            false,
27693            false,
27694            false,
27695            false,
27696            false,
27697            false,
27698            false,
27699        )
27700        .unwrap_err();
27701
27702        assert!(err.to_string().contains("search aborted"));
27703        assert!(err.to_string().contains("index is stale"));
27704        assert!(!err.to_string().contains("database is locked"));
27705    }
27706
27707    #[test]
27708    fn search_cmd_autoindexes_stale_index_by_default() {
27709        let dir = setup_graph_index();
27710        std::thread::sleep(std::time::Duration::from_millis(50));
27711        std::fs::write(
27712            dir.path().join("main.rs"),
27713            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27714        )
27715        .unwrap();
27716
27717        let result = cmd_search(
27718            "helper".to_string(),
27719            Some(dir.path().to_path_buf()),
27720            5,
27721            Some("lexical".to_string()),
27722            None,
27723            false,
27724            false,
27725            true,
27726            0,
27727            false,
27728            false,
27729            false,
27730            false,
27731            false,
27732            false,
27733            false,
27734        );
27735
27736        assert!(result.is_ok());
27737
27738        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27739        let summary = db.compute_changes(dir.path()).unwrap();
27740        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27741    }
27742
27743    #[test]
27744    fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
27745        let dir = setup_graph_index();
27746        std::thread::sleep(std::time::Duration::from_millis(50));
27747        std::fs::write(
27748            dir.path().join("main.rs"),
27749            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27750        )
27751        .unwrap();
27752        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27753
27754        let result = cmd_search(
27755            "helper".to_string(),
27756            Some(dir.path().to_path_buf()),
27757            5,
27758            Some("lexical".to_string()),
27759            None,
27760            false,
27761            false,
27762            true,
27763            0,
27764            false,
27765            false,
27766            false,
27767            false,
27768            false,
27769            false,
27770            false,
27771        );
27772
27773        assert!(result.is_ok());
27774
27775        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27776        let summary = db.compute_changes(dir.path()).unwrap();
27777        assert_eq!(summary.modified, 1);
27778    }
27779
27780    #[test]
27781    fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
27782        let dir = setup_graph_index();
27783        std::thread::sleep(std::time::Duration::from_millis(50));
27784        std::fs::write(
27785            dir.path().join("main.rs"),
27786            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27787        )
27788        .unwrap();
27789        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27790
27791        let err = cmd_search(
27792            "helper".to_string(),
27793            Some(dir.path().to_path_buf()),
27794            5,
27795            Some("lexical".to_string()),
27796            None,
27797            false,
27798            false,
27799            true,
27800            0,
27801            false,
27802            false,
27803            false,
27804            false,
27805            false,
27806            false,
27807            false,
27808        )
27809        .unwrap_err();
27810
27811        let msg = err.to_string();
27812        assert!(msg.contains("autoindexing index"));
27813        assert!(msg.contains("lock diagnostics:"));
27814        assert!(msg.contains("journal: present"));
27815        assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
27816    }
27817
27818    #[test]
27819    fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
27820        let dir = setup_graph_index();
27821        let nested = dir.path().join("src/nested");
27822        std::fs::create_dir_all(&nested).unwrap();
27823
27824        let result = cmd_search(
27825            "helper".to_string(),
27826            Some(nested.clone()),
27827            5,
27828            Some("lexical".to_string()),
27829            None,
27830            false,
27831            false,
27832            true,
27833            0,
27834            false,
27835            false,
27836            false,
27837            false,
27838            false,
27839            false,
27840            false,
27841        );
27842
27843        assert!(result.is_ok());
27844        assert!(!nested.join(".tsift/index.db").exists());
27845    }
27846
27847    #[test]
27848    fn exact_search_returns_literal_matches() {
27849        let dir = tempfile::tempdir().unwrap();
27850        std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
27851
27852        let response = run_exact_search_with_timeout(
27853            std::slice::from_ref(&dir.path().to_path_buf()),
27854            "claudescore-3",
27855            5,
27856            0,
27857        )
27858        .unwrap();
27859
27860        assert_eq!(response.strategy, "exact");
27861        assert_eq!(response.hits.len(), 1);
27862        assert!(response.hits[0].path.ends_with("notes.txt"));
27863        assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
27864        assert!(response.hits[0].snippet.contains("claudescore-3"));
27865    }
27866
27867    #[test]
27868    fn exact_search_skips_stale_index_precheck() {
27869        let dir = setup_graph_index();
27870        std::thread::sleep(std::time::Duration::from_millis(50));
27871        std::fs::write(
27872            dir.path().join("main.rs"),
27873            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
27874        )
27875        .unwrap();
27876
27877        let result = cmd_search(
27878            "println!(\"updated\")".to_string(),
27879            Some(dir.path().to_path_buf()),
27880            5,
27881            Some("exact".to_string()),
27882            None,
27883            false,
27884            false,
27885            false,
27886            0,
27887            false,
27888            false,
27889            false,
27890            false,
27891            false,
27892            false,
27893            false,
27894        );
27895
27896        assert!(result.is_ok());
27897    }
27898
27899    #[test]
27900    fn workspace_exact_search_does_not_require_shared_root_index() {
27901        let dir = setup_workspace();
27902        cmd_index(
27903            dir.path(),
27904            false,
27905            false,
27906            false,
27907            false,
27908            false,
27909            true,
27910            None,
27911            false,
27912            false,
27913            false,
27914            false,
27915            false,
27916            false,
27917        )
27918        .unwrap();
27919
27920        let result = cmd_search(
27921            "alpha_helper".to_string(),
27922            Some(dir.path().to_path_buf()),
27923            5,
27924            Some("exact".to_string()),
27925            None,
27926            false,
27927            false,
27928            false,
27929            0,
27930            false,
27931            false,
27932            false,
27933            false,
27934            false,
27935            false,
27936            false,
27937        );
27938
27939        assert!(result.is_ok());
27940        assert!(!dir.path().join(".tsift/index.db").exists());
27941    }
27942
27943    #[test]
27944    fn identifier_like_query_prefers_exact_search() {
27945        assert!(query_prefers_exact_search("claudescore-3"));
27946        assert!(query_prefers_exact_search("alpha_helper"));
27947        assert!(query_prefers_exact_search("src/main.rs"));
27948        assert!(query_prefers_exact_search("crate::module"));
27949        assert!(!query_prefers_exact_search("authenticate"));
27950        assert!(!query_prefers_exact_search("fn main"));
27951        assert!(!query_prefers_exact_search("."));
27952    }
27953
27954    #[test]
27955    fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
27956        assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
27957        assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
27958        assert_eq!(
27959            resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
27960            "hybrid"
27961        );
27962    }
27963
27964    #[test]
27965    fn workspace_identifier_like_search_auto_uses_exact_backend() {
27966        let dir = setup_workspace();
27967        cmd_index(
27968            dir.path(),
27969            false,
27970            false,
27971            false,
27972            false,
27973            false,
27974            true,
27975            None,
27976            false,
27977            false,
27978            false,
27979            false,
27980            false,
27981            false,
27982        )
27983        .unwrap();
27984
27985        let result = cmd_search(
27986            "alpha_helper".to_string(),
27987            Some(dir.path().to_path_buf()),
27988            5,
27989            None,
27990            None,
27991            false,
27992            false,
27993            false,
27994            0,
27995            false,
27996            false,
27997            false,
27998            false,
27999            false,
28000            false,
28001            false,
28002        );
28003
28004        assert!(result.is_ok());
28005        assert!(!dir.path().join(".tsift/index.db").exists());
28006    }
28007
28008    #[test]
28009    fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
28010        let dir = setup_graph_index();
28011        let nested = dir.path().join("src/nested");
28012        std::fs::create_dir_all(&nested).unwrap();
28013        std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
28014
28015        let result = cmd_index(
28016            &nested, false, false, false, false, false, false, None, false, false, false, false,
28017            false, false,
28018        );
28019
28020        assert!(result.is_ok());
28021        assert!(dir.path().join(".tsift/index.db").exists());
28022        assert!(!nested.join(".tsift/index.db").exists());
28023    }
28024
28025    #[test]
28026    fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
28027        let dir = setup_workspace();
28028        let nested = dir.path().join("docs/nested");
28029        std::fs::create_dir_all(&nested).unwrap();
28030
28031        let result = cmd_index(
28032            &nested, false, false, false, false, false, true, None, false, false, false, false,
28033            false, false,
28034        );
28035
28036        let cfg = config::Config::load(dir.path()).unwrap();
28037
28038        assert!(result.is_ok());
28039        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28040        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28041    }
28042
28043    #[test]
28044    fn status_cmd_autoindexes_missing_workspace_scopes() {
28045        let dir = setup_workspace();
28046        let cfg = config::Config::load(dir.path()).unwrap();
28047        let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
28048        let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
28049        let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
28050        alpha_db.apply_changes(&alpha.source_root).unwrap();
28051
28052        let beta_db_path = cfg.db_path_for(dir.path(), "beta");
28053        assert!(!beta_db_path.exists());
28054
28055        cmd_status(
28056            dir.path(),
28057            StatusCommandOptions {
28058                fix: false,
28059                no_fix: false,
28060                json_output: true,
28061                compact: false,
28062                pretty: false,
28063                terse: false,
28064                schema: false,
28065            },
28066        )
28067        .unwrap();
28068
28069        assert!(beta_db_path.exists());
28070        let report = status::check_status(dir.path()).unwrap();
28071        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28072    }
28073
28074    #[test]
28075    fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
28076        let dir = setup_workspace();
28077        let cfg = config::Config::load(dir.path()).unwrap();
28078
28079        cmd_status(
28080            dir.path(),
28081            StatusCommandOptions {
28082                fix: false,
28083                no_fix: false,
28084                json_output: true,
28085                compact: false,
28086                pretty: false,
28087                terse: false,
28088                schema: false,
28089            },
28090        )
28091        .unwrap();
28092
28093        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28094        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28095        let report = status::check_status(dir.path()).unwrap();
28096        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28097    }
28098
28099    #[test]
28100    fn status_cmd_fix_refreshes_stale_index() {
28101        let dir = setup_graph_index();
28102        std::thread::sleep(std::time::Duration::from_millis(50));
28103        std::fs::write(
28104            dir.path().join("main.rs"),
28105            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28106        )
28107        .unwrap();
28108
28109        let report = status::check_status(dir.path()).unwrap();
28110        assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
28111
28112        cmd_status(
28113            dir.path(),
28114            StatusCommandOptions {
28115                fix: false,
28116                no_fix: false,
28117                json_output: true,
28118                compact: false,
28119                pretty: false,
28120                terse: false,
28121                schema: false,
28122            },
28123        )
28124        .unwrap();
28125
28126        let report = status::check_status(dir.path()).unwrap();
28127        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28128    }
28129
28130    #[test]
28131    fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
28132        let dir = setup_graph_index();
28133        let db_path = dir.path().join(".tsift/index.db");
28134        let _lock = hold_wal_database_lock(&db_path);
28135
28136        cmd_status(
28137            dir.path(),
28138            StatusCommandOptions {
28139                fix: false,
28140                no_fix: false,
28141                json_output: true,
28142                compact: false,
28143                pretty: false,
28144                terse: false,
28145                schema: false,
28146            },
28147        )
28148        .unwrap();
28149
28150        let report = status::check_status(dir.path()).unwrap();
28151        assert!(matches!(
28152            report.index,
28153            status::IndexStatus::Fresh {
28154                recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
28155                ..
28156            }
28157        ));
28158        let locks = status::check_locks(dir.path(), None, None).unwrap();
28159        assert!(matches!(
28160            locks.writer_lock,
28161            status::WriterLockStatus::Absent { .. }
28162        ));
28163        assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
28164        assert!(
28165            locks
28166                .recommended_action
28167                .contains("wedged writer holding live WAL sidecars")
28168        );
28169    }
28170
28171    #[test]
28172    fn locks_report_uses_ancestor_project_root_for_nested_paths() {
28173        let dir = setup_graph_index();
28174        let nested = dir.path().join("src/nested");
28175        std::fs::create_dir_all(&nested).unwrap();
28176
28177        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28178        let report = status::check_locks(&root, Some(&nested), None).unwrap();
28179
28180        assert_eq!(report.source_root, dir.path());
28181        assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
28182    }
28183
28184    #[test]
28185    fn workspace_locks_report_infers_scope_from_nested_path() {
28186        let dir = setup_workspace();
28187        cmd_index(
28188            dir.path(),
28189            false,
28190            false,
28191            false,
28192            false,
28193            false,
28194            true,
28195            None,
28196            false,
28197            false,
28198            false,
28199            false,
28200            false,
28201            false,
28202        )
28203        .unwrap();
28204        let nested = dir.path().join("src/alpha/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        let cfg = config::Config::load(dir.path()).unwrap();
28210
28211        assert_eq!(report.label, "submodule `alpha` index");
28212        assert_eq!(report.source_root, dir.path().join("src/alpha"));
28213        assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
28214        assert_eq!(
28215            report.reindex_command,
28216            format!("tsift index --submodule alpha {}", dir.path().display())
28217        );
28218    }
28219
28220    #[test]
28221    fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
28222        let dir = setup_workspace();
28223        cmd_index(
28224            dir.path(),
28225            false,
28226            false,
28227            false,
28228            false,
28229            false,
28230            true,
28231            None,
28232            false,
28233            false,
28234            false,
28235            false,
28236            false,
28237            false,
28238        )
28239        .unwrap();
28240
28241        let alpha = dir.path().join("src/alpha/lib.rs");
28242        std::thread::sleep(std::time::Duration::from_millis(50));
28243        std::fs::write(
28244            &alpha,
28245            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28246        )
28247        .unwrap();
28248
28249        let result = cmd_search(
28250            "alpha_helper".to_string(),
28251            Some(dir.path().to_path_buf()),
28252            5,
28253            Some("lexical".to_string()),
28254            Some("alpha".to_string()),
28255            false,
28256            false,
28257            true,
28258            0,
28259            false,
28260            false,
28261            false,
28262            false,
28263            false,
28264            false,
28265            false,
28266        );
28267
28268        assert!(result.is_ok());
28269
28270        let cfg = config::Config::load(dir.path()).unwrap();
28271        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28272        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28273        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28274    }
28275
28276    #[test]
28277    fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28278        let dir = setup_workspace();
28279        cmd_index(
28280            dir.path(),
28281            false,
28282            false,
28283            false,
28284            false,
28285            false,
28286            true,
28287            None,
28288            false,
28289            false,
28290            false,
28291            false,
28292            false,
28293            false,
28294        )
28295        .unwrap();
28296
28297        let alpha = dir.path().join("src/alpha/lib.rs");
28298        std::thread::sleep(std::time::Duration::from_millis(50));
28299        std::fs::write(
28300            &alpha,
28301            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28302        )
28303        .unwrap();
28304
28305        let cfg = config::Config::load(dir.path()).unwrap();
28306        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28307
28308        let err = cmd_search(
28309            "alpha_helper".to_string(),
28310            Some(dir.path().to_path_buf()),
28311            5,
28312            Some("lexical".to_string()),
28313            Some("alpha".to_string()),
28314            false,
28315            false,
28316            false,
28317            0,
28318            false,
28319            false,
28320            false,
28321            false,
28322            false,
28323            false,
28324            false,
28325        )
28326        .unwrap_err();
28327
28328        assert!(err.to_string().contains("search aborted"));
28329        assert!(err.to_string().contains("submodule `alpha` index"));
28330        assert!(!err.to_string().contains("database is locked"));
28331    }
28332
28333    #[test]
28334    fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
28335        let dir = setup_workspace();
28336        cmd_index(
28337            dir.path(),
28338            false,
28339            false,
28340            false,
28341            false,
28342            false,
28343            true,
28344            None,
28345            false,
28346            false,
28347            false,
28348            false,
28349            false,
28350            false,
28351        )
28352        .unwrap();
28353
28354        let alpha = dir.path().join("src/alpha/lib.rs");
28355        std::thread::sleep(std::time::Duration::from_millis(50));
28356        std::fs::write(
28357            &alpha,
28358            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28359        )
28360        .unwrap();
28361
28362        let result = cmd_search(
28363            "alpha_helper".to_string(),
28364            Some(dir.path().to_path_buf()),
28365            5,
28366            Some("lexical".to_string()),
28367            None,
28368            true,
28369            false,
28370            true,
28371            0,
28372            false,
28373            false,
28374            false,
28375            false,
28376            false,
28377            false,
28378            false,
28379        );
28380
28381        assert!(result.is_ok());
28382
28383        let cfg = config::Config::load(dir.path()).unwrap();
28384        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28385        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28386        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28387    }
28388
28389    #[test]
28390    fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28391        let dir = setup_workspace();
28392        cmd_index(
28393            dir.path(),
28394            false,
28395            false,
28396            false,
28397            false,
28398            false,
28399            true,
28400            None,
28401            false,
28402            false,
28403            false,
28404            false,
28405            false,
28406            false,
28407        )
28408        .unwrap();
28409
28410        let alpha = dir.path().join("src/alpha/lib.rs");
28411        std::thread::sleep(std::time::Duration::from_millis(50));
28412        std::fs::write(
28413            &alpha,
28414            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28415        )
28416        .unwrap();
28417
28418        let cfg = config::Config::load(dir.path()).unwrap();
28419        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28420
28421        let err = cmd_search(
28422            "alpha_helper".to_string(),
28423            Some(dir.path().to_path_buf()),
28424            5,
28425            Some("lexical".to_string()),
28426            None,
28427            true,
28428            false,
28429            false,
28430            30,
28431            false,
28432            false,
28433            false,
28434            false,
28435            false,
28436            false,
28437            false,
28438        )
28439        .unwrap_err();
28440
28441        assert!(err.to_string().contains("stale"));
28442        assert!(err.to_string().contains("submodule `alpha` index"));
28443        assert!(!err.to_string().contains("database is locked"));
28444    }
28445
28446    #[test]
28447    fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
28448        let dir = setup_workspace();
28449        cmd_index(
28450            dir.path(),
28451            false,
28452            false,
28453            false,
28454            false,
28455            false,
28456            true,
28457            None,
28458            false,
28459            false,
28460            false,
28461            false,
28462            false,
28463            false,
28464        )
28465        .unwrap();
28466
28467        let err = cmd_search(
28468            "alpha_helper".to_string(),
28469            Some(dir.path().to_path_buf()),
28470            5,
28471            Some("lexical".to_string()),
28472            None,
28473            false,
28474            false,
28475            true,
28476            0,
28477            false,
28478            false,
28479            false,
28480            false,
28481            false,
28482            false,
28483            false,
28484        )
28485        .unwrap_err();
28486
28487        assert_workspace_search_requires_explicit_target(err);
28488        assert!(!dir.path().join(".tsift/index.db").exists());
28489    }
28490
28491    #[test]
28492    fn workspace_search_cmd_infers_scope_from_nested_path() {
28493        let dir = setup_workspace();
28494        cmd_index(
28495            dir.path(),
28496            false,
28497            false,
28498            false,
28499            false,
28500            false,
28501            true,
28502            None,
28503            false,
28504            false,
28505            false,
28506            false,
28507            false,
28508            false,
28509        )
28510        .unwrap();
28511        let nested = dir.path().join("src/alpha/nested");
28512        std::fs::create_dir_all(&nested).unwrap();
28513
28514        let result = cmd_search(
28515            "alpha_helper".to_string(),
28516            Some(nested),
28517            5,
28518            Some("lexical".to_string()),
28519            None,
28520            false,
28521            false,
28522            false,
28523            0,
28524            false,
28525            false,
28526            false,
28527            false,
28528            false,
28529            false,
28530            false,
28531        );
28532
28533        assert!(result.is_ok());
28534    }
28535
28536    #[test]
28537    fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
28538        let dir = setup_workspace_with_duplicate_leaf_names();
28539        cmd_index(
28540            dir.path(),
28541            false,
28542            false,
28543            false,
28544            false,
28545            false,
28546            true,
28547            None,
28548            false,
28549            false,
28550            false,
28551            false,
28552            false,
28553            false,
28554        )
28555        .unwrap();
28556        let nested = dir.path().join("vendor/foo/nested");
28557        std::fs::create_dir_all(&nested).unwrap();
28558
28559        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28560        let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
28561        let cfg = config::Config::load(dir.path()).unwrap();
28562
28563        assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
28564    }
28565
28566    #[test]
28567    fn graph_cmd_succeeds_while_writer_lock_is_held() {
28568        let dir = setup_graph_index();
28569        let db_path = dir.path().join(".tsift/index.db");
28570        let _lock = hold_write_lock(&db_path);
28571
28572        let result = cmd_graph(
28573            "main",
28574            dir.path(),
28575            false,
28576            false,
28577            None,
28578            20,
28579            false,
28580            true,
28581            false,
28582            false,
28583            false,
28584            false,
28585            false,
28586            TagpathSearchOpts::default(),
28587        );
28588
28589        assert!(result.is_ok());
28590    }
28591
28592    #[test]
28593    fn graph_cmd_autoindexes_stale_index_by_default() {
28594        let dir = setup_graph_index();
28595        std::thread::sleep(std::time::Duration::from_millis(50));
28596        std::fs::write(
28597            dir.path().join("main.rs"),
28598            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28599        )
28600        .unwrap();
28601
28602        let result = cmd_graph(
28603            "helper",
28604            dir.path(),
28605            true,
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        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
28621        let summary = db.compute_changes(dir.path()).unwrap();
28622        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28623    }
28624
28625    #[test]
28626    fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28627        let dir = setup_graph_index();
28628        let db_path = dir.path().join(".tsift/index.db");
28629        let _lock = hold_rollback_journal_lock(&db_path);
28630
28631        let result = cmd_graph(
28632            "main",
28633            dir.path(),
28634            false,
28635            false,
28636            None,
28637            20,
28638            false,
28639            true,
28640            false,
28641            false,
28642            false,
28643            false,
28644            false,
28645            TagpathSearchOpts::default(),
28646        );
28647
28648        assert!(result.is_ok());
28649    }
28650
28651    #[test]
28652    fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
28653        let dir = setup_graph_index();
28654        let nested = dir.path().join("src/nested");
28655        std::fs::create_dir_all(&nested).unwrap();
28656
28657        let result = cmd_graph(
28658            "helper",
28659            &nested,
28660            true,
28661            false,
28662            None,
28663            20,
28664            false,
28665            false,
28666            false,
28667            false,
28668            false,
28669            false,
28670            false,
28671            TagpathSearchOpts::default(),
28672        );
28673
28674        assert!(result.is_ok());
28675    }
28676
28677    #[test]
28678    fn communities_cmd_succeeds_while_writer_lock_is_held() {
28679        let dir = setup_graph_index();
28680        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
28681
28682        let result = cmd_communities(
28683            dir.path(),
28684            None,
28685            1,
28686            10,
28687            false,
28688            false,
28689            false,
28690            false,
28691            false,
28692            false,
28693            TagpathSearchOpts::default(),
28694        );
28695
28696        assert!(result.is_ok());
28697    }
28698
28699    #[test]
28700    fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28701        let dir = setup_graph_index();
28702        let db_path = dir.path().join(".tsift/index.db");
28703        let _lock = hold_rollback_journal_lock(&db_path);
28704
28705        let result = cmd_communities(
28706            dir.path(),
28707            None,
28708            1,
28709            10,
28710            false,
28711            false,
28712            false,
28713            false,
28714            false,
28715            false,
28716            TagpathSearchOpts::default(),
28717        );
28718
28719        assert!(result.is_ok());
28720    }
28721
28722    #[test]
28723    fn lint_finds_entities_from_project_root_index_db() {
28724        let dir = tempfile::tempdir().unwrap();
28725        std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
28726        std::fs::write(
28727            dir.path().join("README.md"),
28728            "alpha_helper should be backticked.\n",
28729        )
28730        .unwrap();
28731        cmd_index(
28732            dir.path(),
28733            false,
28734            false,
28735            false,
28736            false,
28737            false,
28738            false,
28739            None,
28740            false,
28741            false,
28742            false,
28743            false,
28744            false,
28745            false,
28746        )
28747        .unwrap();
28748
28749        let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
28750            .unwrap()
28751            .unwrap();
28752        let entities = lint::collect_entities_from_index_path(&root).unwrap();
28753        let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
28754
28755        assert!(
28756            result
28757                .annotations
28758                .iter()
28759                .any(|ann| ann.text == "alpha_helper")
28760        );
28761    }
28762
28763    // --- search timeout ---
28764
28765    #[test]
28766    fn search_direct_runs_ok() {
28767        let dir = tempfile::tempdir().unwrap();
28768        let search_dir = dir.path().to_path_buf();
28769        let cache_dir = search_dir.join(".tsift/search-cache");
28770        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28771        let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical", None);
28772        assert!(result.is_ok(), "direct search should succeed");
28773        assert!(
28774            cache_dir.exists(),
28775            "search should create the configured cache dir"
28776        );
28777    }
28778
28779    #[test]
28780    fn search_timeout_zero_disables_timeout() {
28781        let dir = tempfile::tempdir().unwrap();
28782        let search_dir = dir.path().to_path_buf();
28783        let cache_dir = search_dir.join(".tsift/search-cache");
28784        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28785        let result =
28786            run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[], None);
28787        assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
28788        assert!(
28789            cache_dir.exists(),
28790            "timeout=0 should keep using the stable search cache dir"
28791        );
28792    }
28793
28794    #[test]
28795    fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
28796        let dir = tempfile::tempdir().unwrap();
28797        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
28798        cmd_index(
28799            dir.path(),
28800            false,
28801            false,
28802            false,
28803            false,
28804            false,
28805            false,
28806            None,
28807            false,
28808            false,
28809            false,
28810            false,
28811            false,
28812            false,
28813        )
28814        .unwrap();
28815        let db_path = dir.path().join(".tsift/index.db");
28816        std::fs::remove_file(&db_path).unwrap();
28817        let search_target = SearchIndexTarget {
28818            label: "index".to_string(),
28819            db_path,
28820            source_root: dir.path().to_path_buf(),
28821            scope_name: None,
28822            reindex_cmd: format!("tsift index {}", dir.path().display()),
28823        };
28824
28825        let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
28826
28827        assert!(message.contains("timed out after 1s"));
28828        assert!(message.contains("index is missing"));
28829        assert!(message.contains("Run `tsift index"));
28830        assert!(!message.contains("search root looks fresh"));
28831    }
28832
28833    #[test]
28834    fn search_worker_output_path_uses_json_suffix() {
28835        let path = next_search_worker_output_path();
28836        assert!(path.extension().is_some_and(|ext| ext == "json"));
28837    }
28838
28839    #[test]
28840    fn fts_search_flag_value_parses_falsy_escape_hatch() {
28841        // #015t Phase 4: FTS5 is the default; only falsy values force legacy.
28842        for falsy in ["0", "false", "FALSE", " no ", "Off"] {
28843            assert!(
28844                fts_flag_value_disabled(falsy),
28845                "{falsy:?} should force the legacy TokenIndex path"
28846            );
28847        }
28848        for keeps_default in ["", "1", "true", "yes", "on", "maybe"] {
28849            assert!(
28850                !fts_flag_value_disabled(keeps_default),
28851                "{keeps_default:?} should keep the FTS5 default"
28852            );
28853        }
28854    }
28855
28856    #[test]
28857    fn run_sift_search_defaults_to_fts_when_index_db_present() {
28858        // #015t Phase 4 cutover: with the flag unset and a root index.db present,
28859        // run_sift_search uses the FTS5 path by default (strategy "fts").
28860        let dir = tempfile::tempdir().unwrap();
28861        let root = dir.path();
28862        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28863        index::IndexDb::open(&root.join(".tsift/index.db"))
28864            .unwrap()
28865            .apply_changes(root)
28866            .unwrap();
28867        let cache_dir = root.join(".tsift/search-cache");
28868
28869        // Guard against an ambient escape-hatch env from a parallel test/shell.
28870        if fts_search_forced_off() {
28871            return;
28872        }
28873        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28874        assert_eq!(response.strategy, "fts");
28875        assert!(response.hits.iter().any(|h| h.path.ends_with("alpha.rs")));
28876    }
28877
28878    #[test]
28879    fn run_sift_search_falls_back_to_lexical_without_index_db() {
28880        // No root index.db (e.g. un-indexed root reaching here directly): the
28881        // legacy TokenIndex/lexical path still serves the query.
28882        let dir = tempfile::tempdir().unwrap();
28883        let root = dir.path();
28884        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28885        let cache_dir = root.join(".tsift/search-cache");
28886
28887        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28888        assert_eq!(response.strategy, "lexical");
28889    }
28890
28891    #[test]
28892    fn run_sift_search_honors_threaded_freshness_verdict() {
28893        // #015t Phase 4b: the caller's freshness verdict overrides the in-engine
28894        // inspect. Some(true) ⇒ FTS without re-walking; Some(false) ⇒ legacy path
28895        // even with a fresh index.db (the degraded-read-only live-results case).
28896        let dir = tempfile::tempdir().unwrap();
28897        let root = dir.path();
28898        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28899        index::IndexDb::open(&root.join(".tsift/index.db"))
28900            .unwrap()
28901            .apply_changes(root)
28902            .unwrap();
28903        let cache_dir = root.join(".tsift/search-cache");
28904
28905        if fts_search_forced_off() {
28906            return;
28907        }
28908        let fresh =
28909            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(true)).unwrap();
28910        assert_eq!(fresh.strategy, "fts");
28911
28912        let stale =
28913            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(false)).unwrap();
28914        assert_eq!(stale.strategy, "lexical");
28915    }
28916
28917    // --- index quiet mode ---
28918
28919    #[test]
28920    fn index_quiet_suppresses_file_list() {
28921        let dir = setup_graph_index();
28922        let result = cmd_index(
28923            dir.path(),
28924            false,
28925            true,
28926            false,
28927            false,
28928            true,
28929            false,
28930            None,
28931            false,
28932            false,
28933            false,
28934            false,
28935            false,
28936            false,
28937        );
28938        assert!(result.is_ok());
28939    }
28940
28941    #[test]
28942    fn index_exit_code_implies_quiet() {
28943        let dir = setup_graph_index();
28944        let result = cmd_index(
28945            dir.path(),
28946            false,
28947            true,
28948            false,
28949            false,
28950            false,
28951            false,
28952            None,
28953            false,
28954            false,
28955            false,
28956            false,
28957            false,
28958            false,
28959        );
28960        assert!(result.is_ok());
28961    }
28962
28963    #[test]
28964    fn index_quiet_json_omits_changes() {
28965        let dir = setup_graph_index();
28966        let result = cmd_index(
28967            dir.path(),
28968            false,
28969            true,
28970            false,
28971            false,
28972            true,
28973            false,
28974            None,
28975            true,
28976            false,
28977            false,
28978            false,
28979            false,
28980            false,
28981        );
28982        assert!(result.is_ok());
28983    }
28984
28985    #[test]
28986    fn cli_workflow_defaults_to_search_topic() {
28987        let cli = parse_cli(["tsift", "workflow"]);
28988        match cli.command {
28989            Some(Commands::Workflow { topic, json }) => {
28990                assert_eq!(topic, "search");
28991                assert!(!json);
28992            }
28993            _ => panic!("expected Workflow command"),
28994        }
28995    }
28996
28997    #[test]
28998    fn search_workflow_recipe_preserves_handles_across_expansions() {
28999        let recipe = workflow::search_workflow_recipe();
29000        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29001        assert_eq!(
29002            step_names,
29003            vec![
29004                "exact-anchor",
29005                "semantic-search",
29006                "explain-symbol",
29007                "summarize-selection",
29008                "digest-expansion"
29009            ]
29010        );
29011        assert!(
29012            recipe
29013                .handle_contract
29014                .iter()
29015                .any(|item| item.contains("originating command"))
29016        );
29017        assert!(
29018            recipe.steps[1]
29019                .preserves
29020                .iter()
29021                .any(|item| item.contains("sfam-*"))
29022        );
29023        assert!(
29024            recipe.steps[2]
29025                .preserves
29026                .iter()
29027                .any(|item| item.contains("ecall-*"))
29028        );
29029        assert!(
29030            recipe.steps[4]
29031                .preserves
29032                .iter()
29033                .any(|item| item.contains("artifact handles"))
29034        );
29035    }
29036
29037    #[test]
29038    fn kg_workflow_recipe_covers_extract_to_evidence() {
29039        let recipe = workflow::kg_workflow_recipe();
29040        assert_eq!(recipe.topic, "kg");
29041        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29042        assert_eq!(
29043            step_names,
29044            vec!["smoke-check", "extract", "status", "refresh", "evidence"]
29045        );
29046        // evidence uses --symbol (not a positional) and has no --budget flag
29047        let evidence = recipe.steps.last().unwrap();
29048        assert!(evidence.command.contains("kg evidence --symbol"));
29049        assert!(!evidence.command.contains("--budget"));
29050        // extract is the write step; reads should not re-extract
29051        assert!(
29052            recipe
29053                .handle_contract
29054                .iter()
29055                .any(|item| item.contains("Extract once"))
29056        );
29057    }
29058
29059    // --- JSON compact vs pretty ---
29060
29061    #[test]
29062    fn to_json_compact_default() {
29063        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29064        let compact = to_json(&val, false, false).unwrap();
29065        assert!(!compact.contains('\n'));
29066        assert!(
29067            compact.contains("\"a\":1")
29068                || compact.contains("\"a\": 1")
29069                || compact.contains("\"a\":")
29070        );
29071    }
29072
29073    #[test]
29074    fn to_json_pretty_indents() {
29075        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29076        let pretty = to_json(&val, true, false).unwrap();
29077        assert!(pretty.contains('\n'));
29078        assert!(pretty.contains("  "));
29079    }
29080
29081    #[test]
29082    fn to_json_compact_is_shorter() {
29083        let val =
29084            serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
29085        let compact = to_json(&val, false, false).unwrap();
29086        let pretty = to_json(&val, true, false).unwrap();
29087        assert!(compact.len() < pretty.len());
29088    }
29089
29090    #[test]
29091    fn terse_renames_keys() {
29092        let val =
29093            serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
29094        let result = to_json(&val, false, true).unwrap();
29095        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29096        assert!(parsed["_s"].is_object());
29097        let d = &parsed["d"];
29098        assert_eq!(d["cf"], "a.rs");
29099        assert_eq!(d["cn"], "main");
29100        assert_eq!(d["csl"], 10);
29101    }
29102
29103    #[test]
29104    fn terse_schema_only_includes_used_keys() {
29105        let val = serde_json::json!({"name": "test", "score": 0.5});
29106        let result = to_json(&val, false, true).unwrap();
29107        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29108        let schema = parsed["_s"].as_object().unwrap();
29109        assert_eq!(schema["n"], "name");
29110        assert_eq!(schema["sc"], "score");
29111        assert!(!schema.contains_key("cf"));
29112    }
29113
29114    #[test]
29115    fn terse_nested_arrays() {
29116        let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
29117        let result = to_json(&val, false, true).unwrap();
29118        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29119        let d = &parsed["d"];
29120        assert_eq!(d["crs"][0]["cn"], "a");
29121        assert_eq!(d["crs"][0]["cf"], "b.rs");
29122    }
29123
29124    #[test]
29125    fn terse_preserves_unknown_keys() {
29126        let val = serde_json::json!({"custom_field": "value", "name": "test"});
29127        let result = to_json(&val, false, true).unwrap();
29128        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29129        let d = &parsed["d"];
29130        assert_eq!(d["custom_field"], "value");
29131        assert_eq!(d["n"], "test");
29132    }
29133
29134    // --- ultra-terse ---
29135
29136    #[test]
29137    fn ultra_terse_strips_properties_from_graph_nodes() {
29138        let val = serde_json::json!({
29139            "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
29140        });
29141        let result = to_json_schema(&val, false, true, true, false).unwrap();
29142        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29143        let node = &parsed["d"]["nodes"][0];
29144        assert_eq!(node["id"], "fn:main");
29145        assert_eq!(node["k"], "fn");
29146        assert_eq!(node["n"], "main");
29147        assert!(node.get("properties").is_none());
29148    }
29149
29150    #[test]
29151    fn ultra_terse_strips_properties_from_graph_edges() {
29152        let val = serde_json::json!({
29153            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
29154        });
29155        let result = to_json_schema(&val, false, true, true, false).unwrap();
29156        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29157        let edge = &parsed["d"]["edges"][0];
29158        assert_eq!(edge["from_id"], "a");
29159        assert_eq!(edge["to_id"], "b");
29160        assert_eq!(edge["k"], "c");
29161        assert!(edge.get("properties").is_none());
29162    }
29163
29164    #[test]
29165    fn ultra_terse_abbreviates_edge_kinds() {
29166        let val = serde_json::json!({
29167            "edges": [
29168                {"from_id": "a", "to_id": "b", "kind": "defines"},
29169                {"from_id": "a", "to_id": "c", "kind": "contains"},
29170                {"from_id": "a", "to_id": "d", "kind": "imports"},
29171                {"from_id": "a", "to_id": "e", "kind": "mentions"},
29172                {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
29173                {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
29174                {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
29175                {"from_id": "a", "to_id": "i", "kind": "uses"},
29176                {"from_id": "a", "to_id": "j", "kind": "parent"},
29177                {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
29178            ]
29179        });
29180        let result = to_json_schema(&val, false, true, true, false).unwrap();
29181        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29182        let edges = &parsed["d"]["edges"].as_array().unwrap();
29183        assert_eq!(edges[0]["k"], "d");
29184        assert_eq!(edges[1]["k"], "ct");
29185        assert_eq!(edges[2]["k"], "i");
29186        assert_eq!(edges[3]["k"], "m");
29187        assert_eq!(edges[4]["k"], "sr");
29188        assert_eq!(edges[5]["k"], "bt");
29189        assert_eq!(edges[6]["k"], "sctx");
29190        assert_eq!(edges[7]["k"], "u");
29191        assert_eq!(edges[8]["k"], "p");
29192        assert_eq!(edges[9]["k"], "unknown_edge");
29193    }
29194
29195    #[test]
29196    fn ultra_terse_strips_provenance_freshness_from_edges() {
29197        let val = serde_json::json!({
29198            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
29199        });
29200        let result = to_json_schema(&val, false, true, true, false).unwrap();
29201        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29202        let edge = &parsed["d"]["edges"][0];
29203        assert!(edge.get("provenance").is_none());
29204        assert!(edge.get("freshness").is_none());
29205        assert_eq!(edge["k"], "c");
29206    }
29207
29208    #[test]
29209    fn ultra_terse_truncates_snippets() {
29210        let long_snippet = "x".repeat(120);
29211        let val = serde_json::json!({"snippet": long_snippet});
29212        let result = to_json_schema(&val, false, true, true, false).unwrap();
29213        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29214        let snipped = parsed["d"]["sn"].as_str().unwrap();
29215        assert_eq!(snipped.len(), 80);
29216        assert!(snipped.ends_with("..."));
29217    }
29218
29219    #[test]
29220    fn ultra_terse_truncates_abbreviated_snippet_key() {
29221        let long_snippet = "y".repeat(100);
29222        let val = serde_json::json!({"snippet": long_snippet});
29223        let result = to_json_schema(&val, false, true, true, false).unwrap();
29224        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29225        let snipped = parsed["d"]["sn"].as_str().unwrap();
29226        assert_eq!(snipped.len(), 80);
29227        assert!(snipped.ends_with("..."));
29228    }
29229
29230    #[test]
29231    fn ultra_terse_compacts_coverage_snapshot() {
29232        let val = serde_json::json!({
29233            "mode": "incremental",
29234            "total_sector_count": 10,
29235            "dirty_sector_count": 2,
29236            "active_rebuild": Some("rebuild-1"),
29237            "completed_dirty_sector_count": 1,
29238            "mounted_sector_count": 8,
29239            "rebuilding_sector_count": 1,
29240            "resumed_sector_count": 3,
29241            "reused_sector_count": 5
29242        });
29243        let result = to_json_schema(&val, false, true, true, false).unwrap();
29244        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29245        let d = &parsed["d"];
29246        assert_eq!(d["mode"], "incremental");
29247        assert_eq!(d["total_sector_count"], 10);
29248        assert_eq!(d["dirty_sector_count"], 2);
29249        assert!(d.get("active_rebuild").is_none());
29250        assert!(d.get("completed_dirty_sector_count").is_none());
29251        assert!(d.get("mounted_sector_count").is_none());
29252        assert!(d.get("rebuilding_sector_count").is_none());
29253        assert!(d.get("resumed_sector_count").is_none());
29254        assert!(d.get("reused_sector_count").is_none());
29255    }
29256
29257    #[test]
29258    fn ultra_terse_short_snippet_unchanged() {
29259        let val = serde_json::json!({"snippet": "short text"});
29260        let result = to_json_schema(&val, false, true, true, false).unwrap();
29261        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29262        assert_eq!(parsed["d"]["sn"], "short text");
29263    }
29264
29265    #[test]
29266    fn ultra_terse_non_graph_object_properties_preserved() {
29267        let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
29268        let result = to_json_schema(&val, false, true, true, false).unwrap();
29269        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29270        assert!(parsed["d"]["config"]["properties"].is_object());
29271    }
29272
29273    // --- schema-then-values ---
29274
29275    #[test]
29276    fn schema_converts_homogeneous_arrays() {
29277        let val = serde_json::json!({"symbols": [
29278            {"name": "foo", "kind": "fn", "line": 10},
29279            {"name": "bar", "kind": "fn", "line": 20}
29280        ]});
29281        let result = to_json_schema(&val, false, false, false, true).unwrap();
29282        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29283        let syms = &parsed["symbols"];
29284        let columns = syms["_c"]
29285            .as_array()
29286            .unwrap()
29287            .iter()
29288            .map(|value| value.as_str().unwrap())
29289            .collect::<Vec<_>>();
29290        let row0 = syms["_r"][0].as_array().unwrap();
29291        let row1 = syms["_r"][1].as_array().unwrap();
29292        let name_index = columns.iter().position(|column| *column == "name").unwrap();
29293        let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
29294        let line_index = columns.iter().position(|column| *column == "line").unwrap();
29295        assert_eq!(row0[name_index], "foo");
29296        assert_eq!(row0[kind_index], "fn");
29297        assert_eq!(row0[line_index], 10);
29298        assert_eq!(row1[name_index], "bar");
29299        assert_eq!(row1[kind_index], "fn");
29300        assert_eq!(row1[line_index], 20);
29301    }
29302
29303    #[test]
29304    fn schema_skips_short_arrays() {
29305        let val = serde_json::json!({"items": [{"name": "only"}]});
29306        let result = to_json_schema(&val, false, false, false, true).unwrap();
29307        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29308        assert!(parsed["items"].is_array());
29309        assert_eq!(parsed["items"][0]["name"], "only");
29310    }
29311
29312    #[test]
29313    fn schema_skips_heterogeneous_arrays() {
29314        let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
29315        let result = to_json_schema(&val, false, false, false, true).unwrap();
29316        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29317        assert!(parsed["items"].is_array());
29318        assert_eq!(parsed["items"][0]["a"], 1);
29319    }
29320
29321    #[test]
29322    fn schema_with_terse_combines() {
29323        let val = serde_json::json!({"callers": [
29324            {"caller_name": "a", "caller_file": "x.rs"},
29325            {"caller_name": "b", "caller_file": "y.rs"}
29326        ]});
29327        let result = to_json_schema(&val, false, true, false, true).unwrap();
29328        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29329        assert!(parsed["_s"].is_object());
29330        let d = &parsed["d"];
29331        let crs = &d["crs"];
29332        assert!(crs["_c"].is_array());
29333        assert!(crs["_r"].is_array());
29334        let columns = crs["_c"]
29335            .as_array()
29336            .unwrap()
29337            .iter()
29338            .map(|value| value.as_str().unwrap())
29339            .collect::<Vec<_>>();
29340        let row = crs["_r"][0].as_array().unwrap();
29341        let name_index = columns.iter().position(|column| *column == "cn").unwrap();
29342        let file_index = columns.iter().position(|column| *column == "cf").unwrap();
29343        assert_eq!(row[name_index], "a");
29344        assert_eq!(row[file_index], "x.rs");
29345    }
29346
29347    #[test]
29348    fn schema_preserves_non_object_arrays() {
29349        let val = serde_json::json!({"tags": ["a", "b", "c"]});
29350        let result = to_json_schema(&val, false, false, false, true).unwrap();
29351        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29352        assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
29353    }
29354
29355    #[test]
29356    fn cli_accepts_global_schema_flag() {
29357        let cli = parse_cli(["tsift", "--schema", "search", "test"]);
29358        assert!(cli.schema);
29359        assert!(matches!(cli.command, Some(Commands::Search { .. })));
29360    }
29361
29362    #[test]
29363    fn cli_accepts_global_envelope_flag() {
29364        let cli = parse_cli([
29365            "tsift",
29366            "--envelope",
29367            "context-pack",
29368            "tasks/software/tsift.md",
29369        ]);
29370        assert!(cli.envelope);
29371        assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
29372    }
29373
29374    #[test]
29375    fn cli_accepts_locks_command() {
29376        let cli = parse_cli(["tsift", "locks"]);
29377        assert!(matches!(cli.command, Some(Commands::Locks { .. })));
29378    }
29379
29380    #[test]
29381    fn cli_parses_memory_budget_guard_command() {
29382        let cli = parse_cli([
29383            "tsift",
29384            "memory",
29385            "budget-guard",
29386            "--file",
29387            "tool.log",
29388            "--budget-tokens",
29389            "1000",
29390            "--json",
29391        ]);
29392        match cli.command {
29393            Some(Commands::Memory {
29394                command:
29395                    crate::cli::MemoryCommand::BudgetGuard {
29396                        file,
29397                        budget_tokens,
29398                        json,
29399                        ..
29400                    },
29401            }) => {
29402                assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
29403                assert_eq!(budget_tokens, 1000);
29404                assert!(json);
29405            }
29406            _ => panic!("expected memory budget-guard command"),
29407        }
29408    }
29409
29410    #[test]
29411    fn cli_parses_memory_capture_agent_doc_closeout_command() {
29412        let cli = parse_cli([
29413            "tsift",
29414            "memory",
29415            "capture-agent-doc-closeout",
29416            ".",
29417            "--session-path",
29418            "tasks/software/tsift.md",
29419            "--prompt-target",
29420            "do [#tsiftmemhooks]",
29421            "--response-summary",
29422            "wired closeout capture",
29423            "--commit-hash",
29424            "abc123",
29425            "--session-check-status",
29426            "clean",
29427            "--json",
29428        ]);
29429        match cli.command {
29430            Some(Commands::Memory {
29431                command:
29432                    crate::cli::MemoryCommand::CaptureAgentDocCloseout {
29433                        path,
29434                        session_path,
29435                        prompt_target,
29436                        response_summary,
29437                        commit_hash,
29438                        session_check_status,
29439                        json,
29440                    },
29441            }) => {
29442                assert_eq!(path, std::path::PathBuf::from("."));
29443                assert_eq!(
29444                    session_path,
29445                    std::path::PathBuf::from("tasks/software/tsift.md")
29446                );
29447                assert_eq!(prompt_target, "do [#tsiftmemhooks]");
29448                assert_eq!(response_summary, "wired closeout capture");
29449                assert_eq!(commit_hash.as_deref(), Some("abc123"));
29450                assert_eq!(session_check_status, "clean");
29451                assert!(json);
29452            }
29453            _ => panic!("expected memory capture-agent-doc-closeout command"),
29454        }
29455    }
29456
29457    #[test]
29458    fn cli_parses_memory_project_graph_read_policy() {
29459        let cli = parse_cli([
29460            "tsift",
29461            "memory",
29462            "project-graph",
29463            ".",
29464            "--read-policy",
29465            "query-relevant",
29466            "--query",
29467            "semantic memory",
29468            "--limit",
29469            "7",
29470            "--json",
29471        ]);
29472        match cli.command {
29473            Some(Commands::Memory {
29474                command:
29475                    crate::cli::MemoryCommand::ProjectGraph {
29476                        read_policy,
29477                        query,
29478                        limit,
29479                        json,
29480                        ..
29481                    },
29482            }) => {
29483                assert_eq!(
29484                    read_policy,
29485                    crate::cli::MemoryProjectReadPolicy::QueryRelevant
29486                );
29487                assert_eq!(query.as_deref(), Some("semantic memory"));
29488                assert_eq!(limit, 7);
29489                assert!(json);
29490            }
29491            _ => panic!("expected memory project-graph command"),
29492        }
29493    }
29494
29495    #[test]
29496    fn cli_locks_accepts_scope_flag() {
29497        let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
29498        match cli.command {
29499            Some(Commands::Locks { scope, .. }) => {
29500                assert_eq!(scope.as_deref(), Some("alpha"));
29501            }
29502            _ => panic!("expected Locks command"),
29503        }
29504    }
29505
29506    #[test]
29507    fn cli_search_accepts_autoindex_flag() {
29508        let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
29509        match cli.command {
29510            Some(Commands::Search {
29511                autoindex,
29512                no_autoindex,
29513                ..
29514            }) => {
29515                assert!(autoindex);
29516                assert!(!no_autoindex);
29517            }
29518            _ => panic!("expected Search command"),
29519        }
29520    }
29521
29522    #[test]
29523    fn cli_search_accepts_exact_flag() {
29524        let cli = parse_cli(["tsift", "search", "test", "--exact"]);
29525        match cli.command {
29526            Some(Commands::Search {
29527                exact, strategy, ..
29528            }) => {
29529                assert!(exact);
29530                assert!(strategy.is_none());
29531            }
29532            _ => panic!("expected Search command"),
29533        }
29534    }
29535
29536    #[test]
29537    fn cli_parses_diff_digest_command() {
29538        let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
29539        match cli.command {
29540            Some(Commands::DiffDigest {
29541                json,
29542                path,
29543                cached,
29544                revision,
29545                max_parsed_files,
29546            }) => {
29547                assert!(json);
29548                assert_eq!(path, PathBuf::from("."));
29549                assert!(!cached);
29550                assert!(revision.is_none());
29551                assert_eq!(max_parsed_files, 25);
29552            }
29553            _ => panic!("expected DiffDigest command"),
29554        }
29555    }
29556
29557    #[test]
29558    fn cli_rejects_conflicting_diff_digest_modes() {
29559        match try_parse_cli([
29560            "tsift",
29561            "diff-digest",
29562            "--cached",
29563            "--revision",
29564            "HEAD",
29565            ".",
29566        ]) {
29567            Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
29568            Err(err) => {
29569                assert!(err.to_string().contains("--cached"));
29570                assert!(err.to_string().contains("--revision"));
29571            }
29572        }
29573    }
29574
29575    #[test]
29576    fn cli_parses_test_digest_command() {
29577        let cli = parse_cli([
29578            "tsift",
29579            "test-digest",
29580            "--path",
29581            ".",
29582            "--input",
29583            "target/test.log",
29584            "--runner",
29585            "cargo",
29586            "--json",
29587        ]);
29588        match cli.command {
29589            Some(Commands::TestDigest {
29590                json,
29591                path,
29592                input,
29593                runner,
29594            }) => {
29595                assert!(json);
29596                assert_eq!(path, PathBuf::from("."));
29597                assert_eq!(input, Some(PathBuf::from("target/test.log")));
29598                assert_eq!(runner.as_deref(), Some("cargo"));
29599            }
29600            _ => panic!("expected TestDigest command"),
29601        }
29602    }
29603
29604    #[test]
29605    fn cli_parses_log_digest_command() {
29606        let cli = parse_cli([
29607            "tsift",
29608            "log-digest",
29609            "--path",
29610            ".",
29611            "--input",
29612            "target/build.log",
29613            "--json",
29614        ]);
29615        match cli.command {
29616            Some(Commands::LogDigest {
29617                json,
29618                path,
29619                input,
29620                fixture,
29621                fail_under,
29622            }) => {
29623                assert!(json);
29624                assert_eq!(path, PathBuf::from("."));
29625                assert_eq!(input, Some(PathBuf::from("target/build.log")));
29626                assert!(fixture.is_none());
29627                assert!(!fail_under);
29628            }
29629            _ => panic!("expected LogDigest command"),
29630        }
29631    }
29632
29633    #[test]
29634    fn cli_parses_metric_digest_command() {
29635        let cli = parse_cli([
29636            "tsift",
29637            "metric-digest",
29638            "--input",
29639            "target/runs.json",
29640            "--baseline",
29641            "target/prior.json",
29642            "--metric",
29643            "session_mae",
29644            "--lower-is-better",
29645            "session_mae",
29646            "--history",
29647            "4",
29648            "--top",
29649            "2",
29650            "--json",
29651        ]);
29652        match cli.command {
29653            Some(Commands::MetricDigest {
29654                input,
29655                baseline,
29656                metrics,
29657                lower_is_better,
29658                history,
29659                top,
29660                json,
29661                ..
29662            }) => {
29663                assert!(json);
29664                assert_eq!(input, Some(PathBuf::from("target/runs.json")));
29665                assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
29666                assert_eq!(metrics, vec!["session_mae"]);
29667                assert_eq!(lower_is_better, vec!["session_mae"]);
29668                assert_eq!(history, 4);
29669                assert_eq!(top, 2);
29670            }
29671            _ => panic!("expected MetricDigest command"),
29672        }
29673    }
29674
29675    #[test]
29676    fn cli_parses_dci_benchmark_command() {
29677        let cli = parse_cli([
29678            "tsift",
29679            "dci-benchmark",
29680            "--fixture",
29681            "fixtures/dci-search-benchmark.json",
29682            "--json",
29683        ]);
29684        match cli.command {
29685            Some(Commands::DciBenchmark { fixture, json }) => {
29686                assert!(json);
29687                assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
29688            }
29689            _ => panic!("expected DciBenchmark command"),
29690        }
29691    }
29692
29693    #[test]
29694    fn cli_parses_session_digest_command() {
29695        let cli = parse_cli([
29696            "tsift",
29697            "session-digest",
29698            "--path",
29699            ".",
29700            "--input",
29701            "target/session.md",
29702            "--source",
29703            "markdown",
29704            "--json",
29705        ]);
29706        match cli.command {
29707            Some(Commands::SessionDigest {
29708                json,
29709                path,
29710                input,
29711                source,
29712            }) => {
29713                assert!(json);
29714                assert_eq!(path, PathBuf::from("."));
29715                assert_eq!(input, Some(PathBuf::from("target/session.md")));
29716                assert_eq!(source.as_deref(), Some("markdown"));
29717            }
29718            _ => panic!("expected SessionDigest command"),
29719        }
29720    }
29721
29722    #[test]
29723    fn cli_parses_session_cost_command() {
29724        let cli = parse_cli([
29725            "tsift",
29726            "session-cost",
29727            "--input",
29728            "target/session.jsonl",
29729            "--source",
29730            "codex-jsonl",
29731            "--json",
29732        ]);
29733        match cli.command {
29734            Some(Commands::SessionCost {
29735                json,
29736                input,
29737                fixture,
29738                fail_under,
29739                source,
29740            }) => {
29741                assert!(json);
29742                assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
29743                assert_eq!(fixture, None);
29744                assert!(!fail_under);
29745                assert_eq!(source.as_deref(), Some("codex-jsonl"));
29746            }
29747            _ => panic!("expected SessionCost command"),
29748        }
29749
29750        let cli = parse_cli([
29751            "tsift",
29752            "session-cost",
29753            "--fixture",
29754            "fixtures/real-session-prompt-cache-effectiveness.json",
29755            "--fail-under",
29756            "--json",
29757        ]);
29758        match cli.command {
29759            Some(Commands::SessionCost {
29760                json,
29761                input,
29762                fixture,
29763                fail_under,
29764                source,
29765            }) => {
29766                assert!(json);
29767                assert_eq!(input, None);
29768                assert_eq!(
29769                    fixture,
29770                    Some(PathBuf::from(
29771                        "fixtures/real-session-prompt-cache-effectiveness.json"
29772                    ))
29773                );
29774                assert!(fail_under);
29775                assert_eq!(source, None);
29776            }
29777            _ => panic!("expected SessionCost command"),
29778        }
29779    }
29780
29781    #[test]
29782    fn cli_parses_session_review_command() {
29783        let cli = parse_cli([
29784            "tsift",
29785            "session-review",
29786            "tasks/software/tsift.md",
29787            "--next-context",
29788            "--json",
29789        ]);
29790        match cli.command {
29791            Some(Commands::SessionReview {
29792                json,
29793                next_context,
29794                path,
29795                ..
29796            }) => {
29797                assert!(json);
29798                assert!(next_context);
29799                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29800            }
29801            _ => panic!("expected SessionReview command"),
29802        }
29803    }
29804
29805    #[test]
29806    fn cli_search_accepts_budget_flags() {
29807        let cli = parse_cli([
29808            "tsift",
29809            "search",
29810            "alpha_helper",
29811            "--max-items",
29812            "3",
29813            "--max-bytes",
29814            "96",
29815        ]);
29816        match cli.command {
29817            Some(Commands::Search {
29818                max_items,
29819                max_bytes,
29820                ..
29821            }) => {
29822                assert_eq!(max_items, Some(3));
29823                assert_eq!(max_bytes, Some(96));
29824            }
29825            _ => panic!("expected Search command"),
29826        }
29827    }
29828
29829    #[test]
29830    fn cli_search_accepts_budget_preset() {
29831        let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
29832        match cli.command {
29833            Some(Commands::Search { budget, .. }) => {
29834                assert_eq!(budget, Some(ResponseBudgetPreset::Small));
29835            }
29836            _ => panic!("expected Search command"),
29837        }
29838    }
29839
29840    #[test]
29841    fn cli_search_accepts_ast_facet_filters() {
29842        let cli = parse_cli([
29843            "tsift",
29844            "search",
29845            "setup",
29846            "--lang",
29847            "markdown",
29848            "--kind",
29849            "list_item",
29850            "--node-kind",
29851            "list_item",
29852            "--section",
29853            "Install",
29854            "--parent",
29855            "Run setup.",
29856            "--child",
29857            "Confirm setup.",
29858            "--fence-language",
29859            "rust",
29860            "--list-depth",
29861            "1",
29862            "--heading-level",
29863            "2",
29864        ]);
29865        match cli.command {
29866            Some(Commands::Search {
29867                lang,
29868                kind,
29869                node_kind,
29870                section,
29871                parent,
29872                child,
29873                fence_language,
29874                list_depth,
29875                heading_level,
29876                ..
29877            }) => {
29878                assert_eq!(lang, vec!["markdown"]);
29879                assert_eq!(kind, vec!["list_item"]);
29880                assert_eq!(node_kind, vec!["list_item"]);
29881                assert_eq!(section, vec!["Install"]);
29882                assert_eq!(parent, vec!["Run setup."]);
29883                assert_eq!(child, vec!["Confirm setup."]);
29884                assert_eq!(fence_language, vec!["rust"]);
29885                assert_eq!(list_depth, vec![1]);
29886                assert_eq!(heading_level, vec![2]);
29887            }
29888            _ => panic!("expected Search command"),
29889        }
29890    }
29891
29892    #[test]
29893    fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
29894        let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
29895        assert_eq!(small.preview_items(), 3);
29896        assert_eq!(small.preview_bytes(), 120);
29897        assert_eq!(small.follow_up_items(), 4);
29898
29899        let overridden =
29900            ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
29901        assert_eq!(overridden.preview_items(), 7);
29902        assert_eq!(overridden.preview_bytes(), 120);
29903        assert_eq!(overridden.follow_up_items(), 7);
29904
29905        let envelope_default = ResponseBudget::from_cli(None, None, None, true);
29906        assert!(envelope_default.is_active());
29907    }
29908
29909    #[test]
29910    fn cli_explain_accepts_budget_flags() {
29911        let cli = parse_cli([
29912            "tsift",
29913            "explain",
29914            "alpha_helper",
29915            "--max-items",
29916            "2",
29917            "--max-bytes",
29918            "80",
29919        ]);
29920        match cli.command {
29921            Some(Commands::Explain {
29922                max_items,
29923                max_bytes,
29924                ..
29925            }) => {
29926                assert_eq!(max_items, Some(2));
29927                assert_eq!(max_bytes, Some(80));
29928            }
29929            _ => panic!("expected Explain command"),
29930        }
29931    }
29932
29933    #[test]
29934    fn cli_session_review_accepts_budget_flags() {
29935        let cli = parse_cli([
29936            "tsift",
29937            "session-review",
29938            "tasks/software/tsift.md",
29939            "--max-items",
29940            "4",
29941            "--max-bytes",
29942            "120",
29943        ]);
29944        match cli.command {
29945            Some(Commands::SessionReview {
29946                max_items,
29947                max_bytes,
29948                ..
29949            }) => {
29950                assert_eq!(max_items, Some(4));
29951                assert_eq!(max_bytes, Some(120));
29952            }
29953            _ => panic!("expected SessionReview command"),
29954        }
29955    }
29956
29957    #[test]
29958    fn cli_parses_context_pack_command() {
29959        let cli = parse_cli([
29960            "tsift",
29961            "context-pack",
29962            "tasks/software/tsift.md",
29963            "--test-input",
29964            "target/test.log",
29965            "--runner",
29966            "cargo",
29967            "--log-input",
29968            "target/build.log",
29969            "--max-items",
29970            "3",
29971            "--max-bytes",
29972            "96",
29973            "--json",
29974        ]);
29975        match cli.command {
29976            Some(Commands::ContextPack {
29977                path,
29978                test_input,
29979                runner,
29980                log_input,
29981                json,
29982                max_items,
29983                max_bytes,
29984                budget,
29985                convex_snapshot,
29986            }) => {
29987                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29988                assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
29989                assert_eq!(runner.as_deref(), Some("cargo"));
29990                assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
29991                assert!(json);
29992                assert_eq!(max_items, Some(3));
29993                assert_eq!(max_bytes, Some(96));
29994                assert!(budget.is_none());
29995                assert!(convex_snapshot.is_none());
29996            }
29997            _ => panic!("expected ContextPack command"),
29998        }
29999    }
30000
30001    #[test]
30002    fn cli_parses_token_savings_command() {
30003        let cli = parse_cli([
30004            "tsift",
30005            "token-savings",
30006            "--fixture",
30007            "fixtures/tsift-token-savings.json",
30008            "--fail-under",
30009            "--json",
30010        ]);
30011        match cli.command {
30012            Some(Commands::TokenSavings {
30013                fixture,
30014                fail_under,
30015                json,
30016            }) => {
30017                assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
30018                assert!(fail_under);
30019                assert!(json);
30020            }
30021            _ => panic!("expected TokenSavings command"),
30022        }
30023    }
30024
30025    #[test]
30026    fn token_savings_report_records_fixture_thresholds() {
30027        let raw_symbols = [
30028            "validate_user",
30029            "validateUser",
30030            "ValidateUser",
30031            "validate-user",
30032            "VALIDATE_USER",
30033            "Validate_User",
30034            "raw_symbol",
30035            "rawSymbol",
30036            "RawSymbol",
30037            "raw-symbol",
30038            "RAW_SYMBOL",
30039            "Raw_Symbol",
30040        ]
30041        .iter()
30042        .enumerate()
30043        .map(|(idx, identifier)| TokenSavingsRawSymbol {
30044            identifier: (*identifier).to_string(),
30045            file: format!("src/example_{idx}.rs"),
30046            line: (idx + 1) as u64,
30047            context: "function".to_string(),
30048        })
30049        .collect();
30050        let fixture = TokenSavingsFixture {
30051            schema_version: 1,
30052            description: "fixture".to_string(),
30053            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30054            cases: vec![TokenSavingsFixtureCase {
30055                name: "search-preview".to_string(),
30056                surface: "search".to_string(),
30057                minimum_savings_percent: 40.0,
30058                raw_symbols,
30059                tagpath_families: vec![
30060                    TokenSavingsFamily {
30061                        canonical: "validate_user".to_string(),
30062                        count: 6,
30063                        aliases: BTreeMap::new(),
30064                    },
30065                    TokenSavingsFamily {
30066                        canonical: "raw_symbol".to_string(),
30067                        count: 6,
30068                        aliases: BTreeMap::new(),
30069                    },
30070                ],
30071                context_pack_inputs: None,
30072                session_review_inputs: None,
30073                source_read_inputs: None,
30074                markdown_projection_inputs: None,
30075            }],
30076        };
30077
30078        let report = build_token_savings_report(&fixture).unwrap();
30079
30080        assert!(report.pass);
30081        assert_eq!(report.cases[0].raw_symbol_count, 12);
30082        assert_eq!(report.cases[0].family_count, 2);
30083        assert_eq!(report.cases[0].status, "pass");
30084        assert!(report.cases[0].byte_delta > 0);
30085        assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
30086        assert!(report.cases[0].savings_percent >= 40.0);
30087    }
30088
30089    #[test]
30090    fn token_savings_source_read_inputs_preserve_required_anchors() {
30091        let fixture = TokenSavingsFixture {
30092            schema_version: 1,
30093            description: "fixture".to_string(),
30094            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30095            cases: vec![TokenSavingsFixtureCase {
30096                name: "source-read".to_string(),
30097                surface: "source-read".to_string(),
30098                minimum_savings_percent: 40.0,
30099                raw_symbols: Vec::new(),
30100                tagpath_families: Vec::new(),
30101                context_pack_inputs: None,
30102                session_review_inputs: None,
30103                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30104                    reads: vec![TokenSavingsSourceReadInput {
30105                        command: "sed -n '40,160p' src/main.rs".to_string(),
30106                        file: "src/main.rs".to_string(),
30107                        raw_start: 40,
30108                        raw_lines: 121,
30109                        raw_excerpt: "line 40\n".repeat(121),
30110                        envelope_start: 40,
30111                        envelope_lines: 121,
30112                        required_line_anchors: vec![40, 120, 160],
30113                    }],
30114                }),
30115                markdown_projection_inputs: None,
30116            }],
30117        };
30118
30119        let report = build_token_savings_report(&fixture).unwrap();
30120
30121        assert!(report.pass);
30122        assert_eq!(report.cases[0].surface, "source-read");
30123        assert!(report.cases[0].savings_percent >= 40.0);
30124    }
30125
30126    #[test]
30127    fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
30128        let fixture = TokenSavingsFixture {
30129            schema_version: 1,
30130            description: "fixture".to_string(),
30131            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30132            cases: vec![TokenSavingsFixtureCase {
30133                name: "source-read".to_string(),
30134                surface: "source-read".to_string(),
30135                minimum_savings_percent: 40.0,
30136                raw_symbols: Vec::new(),
30137                tagpath_families: Vec::new(),
30138                context_pack_inputs: None,
30139                session_review_inputs: None,
30140                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30141                    reads: vec![TokenSavingsSourceReadInput {
30142                        command: "cat src/main.rs".to_string(),
30143                        file: "src/main.rs".to_string(),
30144                        raw_start: 1,
30145                        raw_lines: 200,
30146                        raw_excerpt: "line\n".repeat(200),
30147                        envelope_start: 1,
30148                        envelope_lines: 80,
30149                        required_line_anchors: vec![120],
30150                    }],
30151                }),
30152                markdown_projection_inputs: None,
30153            }],
30154        };
30155
30156        let err = match build_token_savings_report(&fixture) {
30157            Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
30158            Err(err) => err,
30159        };
30160
30161        assert!(err.to_string().contains("hides required line anchor 120"));
30162    }
30163
30164    #[test]
30165    fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
30166        let fixture = TokenSavingsFixture {
30167            schema_version: 1,
30168            description: "fixture".to_string(),
30169            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30170            cases: vec![TokenSavingsFixtureCase {
30171                name: "markdown-projection".to_string(),
30172                surface: "context-pack".to_string(),
30173                minimum_savings_percent: 40.0,
30174                raw_symbols: Vec::new(),
30175                tagpath_families: Vec::new(),
30176                context_pack_inputs: None,
30177                session_review_inputs: None,
30178                source_read_inputs: None,
30179                markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
30180                    documents: vec![TokenSavingsMarkdownProjectionInput {
30181                        command: "context-pack markdown body".to_string(),
30182                        file: "tasks/software/tsift.md".to_string(),
30183                        raw_markdown: "# Heading\n\n".repeat(120),
30184                        outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
30185                        selected_nodes: vec!["mdast-selected".to_string()],
30186                        expand:
30187                            "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
30188                                .to_string(),
30189                    }],
30190                }),
30191            }],
30192        };
30193
30194        let report = build_token_savings_report(&fixture).unwrap();
30195
30196        assert!(report.pass);
30197        assert_eq!(report.cases[0].surface, "context-pack");
30198        assert!(report.cases[0].savings_percent >= 40.0);
30199    }
30200
30201    #[test]
30202    fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
30203        let mut content = String::from("# Cache Root\n\n");
30204        for idx in 0..96 {
30205            content.push_str(&format!(
30206                "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
30207            ));
30208        }
30209
30210        let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30211        assert!(!first.cache_hit);
30212        assert!(first.nodes.len() > 200);
30213
30214        let sections = markdown_section_spans(&content).unwrap();
30215        let list_items = markdown_block_spans(&content, "list_item").unwrap();
30216        let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
30217        let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30218
30219        assert!(second.cache_hit);
30220        assert_eq!(second.nodes.len(), first.nodes.len());
30221        assert_eq!(sections.len(), 97);
30222        assert_eq!(list_items.len(), 96);
30223        assert_eq!(code_blocks.len(), 96);
30224        let first_code = first
30225            .nodes
30226            .iter()
30227            .find(|node| node.kind == "code_block")
30228            .expect("expected a Markdown code block");
30229        let first_code_node = markdown_ast_node(
30230            Path::new("/repo"),
30231            "semantic-edit",
30232            first_code,
30233            content.as_bytes(),
30234            &first.nodes,
30235            8,
30236        );
30237        assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
30238        assert_eq!(
30239            first_code_node.metadata.embedded_symbols[0].name,
30240            "sample_0"
30241        );
30242        assert_eq!(
30243            first_code_node.metadata.embedded_symbols[0].language,
30244            "rust"
30245        );
30246    }
30247
30248    #[test]
30249    fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
30250        let response = empty_search_response(Path::new("/repo"), "lexical");
30251        let symbol_hits = vec![index::SymbolHit {
30252            name: "alpha_helper_with_a_long_name".to_string(),
30253            kind: "function".to_string(),
30254            language: "rust".to_string(),
30255            file: "/repo/src/lib.rs".to_string(),
30256            line: 12,
30257            end_line: None,
30258            node_kind: None,
30259            start_byte: None,
30260            end_byte: None,
30261            body_start_byte: None,
30262            body_end_byte: None,
30263            tags: None,
30264            score: 0.98,
30265            match_type: "exact_name".to_string(),
30266            tagpath_handle: None,
30267        }];
30268
30269        let report = build_relative_search_budget_report(
30270            "alpha_helper_with_a_long_name",
30271            "lexical",
30272            Path::new("/repo"),
30273            &response,
30274            &symbol_hits,
30275            ResponseBudget::new(Some(1), Some(12)),
30276            &SearchFacetFilters::default(),
30277        );
30278
30279        assert_eq!(report.symbols.len(), 1);
30280        assert!(report.symbols[0].handle.starts_with("sfam-"));
30281        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
30282        assert_eq!(report.symbols[0].name, "alpha_hel...");
30283        assert_eq!(report.symbols[0].file, "src/lib.rs");
30284        assert!(report.symbols[0].expand.contains("tsift search"));
30285    }
30286
30287    #[test]
30288    fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
30289        let dir = tempfile::tempdir().unwrap();
30290        let src_dir = dir.path().join("src");
30291        fs::create_dir_all(&src_dir).unwrap();
30292        let source = "fn alpha_helper() {\n    beta();\n}\n";
30293        let file = src_dir.join("lib.rs");
30294        fs::write(&file, source).unwrap();
30295        let body_start = source.find("{\n").unwrap() + 1;
30296        let body_end = source.rfind("\n}").unwrap() + 1;
30297
30298        let response = empty_search_response(dir.path(), "lexical");
30299        let symbol_hits = vec![index::SymbolHit {
30300            name: "alpha_helper".to_string(),
30301            kind: "function".to_string(),
30302            language: "rust".to_string(),
30303            file: file.to_string_lossy().to_string(),
30304            line: 0,
30305            end_line: Some(2),
30306            node_kind: Some("function_item".to_string()),
30307            start_byte: Some(0),
30308            end_byte: Some(i64::try_from(source.len()).unwrap()),
30309            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30310            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30311            tags: Some("alpha,helper".to_string()),
30312            score: 0.98,
30313            match_type: "exact_name".to_string(),
30314            tagpath_handle: None,
30315        }];
30316
30317        let report = build_relative_search_budget_report(
30318            "alpha helper",
30319            "lexical",
30320            dir.path(),
30321            &response,
30322            &symbol_hits,
30323            ResponseBudget::new(Some(5), Some(96)),
30324            &SearchFacetFilters::default(),
30325        );
30326
30327        let symbol = &report.symbols[0];
30328        assert_eq!(symbol.language, "rust");
30329        assert_eq!(symbol.end_line, Some(2));
30330        let ast = symbol
30331            .ast
30332            .as_ref()
30333            .expect("search symbol preview should expose an AST span artifact");
30334        assert_eq!(ast.artifact_kind, "ast_span");
30335        assert!(ast.span.handle.starts_with("span-"));
30336        assert_eq!(ast.span.node_kind, "function_item");
30337        assert_eq!(ast.span.start_byte, 0);
30338        assert_eq!(ast.span.end_byte, source.len());
30339        assert_eq!(ast.span.body_start_byte, Some(body_start));
30340        assert_eq!(ast.span.body_end_byte, Some(body_end));
30341        assert!(ast.expand.source_window.contains("source-read"));
30342        assert!(
30343            ast.expand
30344                .source_body
30345                .as_ref()
30346                .unwrap()
30347                .contains("source-read")
30348        );
30349        assert!(ast.expand.symbol_read.contains("symbol-read"));
30350        assert!(ast.expand.markdown_ast.is_none());
30351    }
30352
30353    #[test]
30354    fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
30355        let dir = tempfile::tempdir().unwrap();
30356        let source = "# Guide\n\n## Install\n\n- Run setup.\n";
30357        let file = dir.path().join("README.md");
30358        fs::write(&file, source).unwrap();
30359        let heading_start = source.find("## Install").unwrap();
30360        let heading_end = source.len();
30361
30362        let response = empty_search_response(dir.path(), "lexical");
30363        let symbol_hits = vec![index::SymbolHit {
30364            name: "Install".to_string(),
30365            kind: "heading".to_string(),
30366            language: "markdown".to_string(),
30367            file: file.to_string_lossy().to_string(),
30368            line: 2,
30369            end_line: Some(4),
30370            node_kind: Some("atx_heading".to_string()),
30371            start_byte: Some(i64::try_from(heading_start).unwrap()),
30372            end_byte: Some(i64::try_from(heading_end).unwrap()),
30373            body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
30374            body_end_byte: Some(i64::try_from(heading_end).unwrap()),
30375            tags: Some("install".to_string()),
30376            score: 1.0,
30377            match_type: "exact_name".to_string(),
30378            tagpath_handle: None,
30379        }];
30380
30381        let report = build_relative_search_budget_report(
30382            "Install",
30383            "lexical",
30384            dir.path(),
30385            &response,
30386            &symbol_hits,
30387            ResponseBudget::new(Some(5), Some(96)),
30388            &SearchFacetFilters::default(),
30389        );
30390
30391        let ast = report.symbols[0]
30392            .ast
30393            .as_ref()
30394            .expect("Markdown search symbol should expose an AST span artifact");
30395        assert_eq!(ast.span.node_kind, "atx_heading");
30396        assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
30397        let markdown_ast = ast
30398            .expand
30399            .markdown_ast
30400            .as_ref()
30401            .expect("Markdown symbols should include markdown-ast expansion");
30402        assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
30403        assert!(markdown_ast.contains("--node"), "{markdown_ast}");
30404        assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
30405        assert!(ast.expand.source_window.contains("source-read"));
30406        assert!(ast.expand.symbol_read.contains("symbol-read"));
30407    }
30408
30409    #[test]
30410    fn search_budget_report_exposes_markdown_embedded_code_symbols() {
30411        let dir = tempfile::tempdir().unwrap();
30412        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30413        let file = dir.path().join("README.md");
30414        fs::write(&file, source).unwrap();
30415        let fence_start = source.find("```rust").unwrap();
30416        let body_start = source.find("fn sample").unwrap();
30417        let body_end = body_start + "fn sample() {}\n".len();
30418
30419        let response = empty_search_response(dir.path(), "lexical");
30420        let symbol_hits = vec![index::SymbolHit {
30421            name: "rust".to_string(),
30422            kind: "code_block".to_string(),
30423            language: "markdown".to_string(),
30424            file: file.to_string_lossy().to_string(),
30425            line: 2,
30426            end_line: Some(4),
30427            node_kind: Some("fenced_code_block".to_string()),
30428            start_byte: Some(i64::try_from(fence_start).unwrap()),
30429            end_byte: Some(i64::try_from(source.len()).unwrap()),
30430            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30431            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30432            tags: Some("rust".to_string()),
30433            score: 1.0,
30434            match_type: "exact_name".to_string(),
30435            tagpath_handle: None,
30436        }];
30437
30438        let report = build_relative_search_budget_report(
30439            "rust",
30440            "lexical",
30441            dir.path(),
30442            &response,
30443            &symbol_hits,
30444            ResponseBudget::new(Some(5), Some(96)),
30445            &SearchFacetFilters::default(),
30446        );
30447
30448        let embedded = &report.symbols[0]
30449            .ast
30450            .as_ref()
30451            .unwrap()
30452            .span
30453            .markdown
30454            .as_ref()
30455            .unwrap()
30456            .embedded_symbols;
30457        assert_eq!(embedded.len(), 1);
30458        assert_eq!(embedded[0].name, "sample");
30459        assert_eq!(embedded[0].kind, "function");
30460        assert_eq!(embedded[0].language, "rust");
30461        assert_eq!(embedded[0].node_kind, "function_item");
30462        assert!(embedded[0].handle.starts_with("span-"));
30463        assert_eq!(embedded[0].start_byte, body_start);
30464        assert_eq!(embedded[0].start_line, 4);
30465    }
30466
30467    fn test_lexical_search_hit(
30468        path: &Path,
30469        rank: usize,
30470        score: f64,
30471        snippet: &str,
30472    ) -> sift::SearchHit {
30473        sift::SearchHit {
30474            artifact_id: format!("hit-{rank}"),
30475            artifact_kind: sift::ContextArtifactKind::File,
30476            budget: sift::ArtifactBudget::from_text(snippet, 1),
30477            confidence: sift::ScoreConfidence::High,
30478            freshness: sift::ArtifactFreshness {
30479                modified_unix_secs: None,
30480                observed_unix_secs: 0,
30481            },
30482            location: Some("line 1".to_string()),
30483            path: path.to_string_lossy().to_string(),
30484            provenance: sift::ArtifactProvenance {
30485                adapter: sift::AcquisitionAdapterKind::FileSystem,
30486                source: "test lexical hit".to_string(),
30487                synthetic: false,
30488            },
30489            rank,
30490            score,
30491            snippet: snippet.to_string(),
30492        }
30493    }
30494
30495    fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
30496        summarize::Summary {
30497            id: 0,
30498            symbol_name: symbol_name.to_string(),
30499            file_path: file_path.to_string(),
30500            content_hash: "hash".to_string(),
30501            summary: summary.to_string(),
30502            entities: None,
30503            relationships: None,
30504            concept_labels: None,
30505            extracted_at: "2026-06-02T00:00:00Z".to_string(),
30506            model: "test".to_string(),
30507            tokens_input: None,
30508            tokens_output: None,
30509        }
30510    }
30511
30512    #[test]
30513    fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
30514        let dir = tempfile::tempdir().unwrap();
30515        let src_dir = dir.path().join("src");
30516        fs::create_dir_all(&src_dir).unwrap();
30517        let source = "fn alpha_helper() {}\n";
30518        let file = src_dir.join("lib.rs");
30519        let broad_file = dir.path().join("README.md");
30520        fs::write(&file, source).unwrap();
30521        fs::write(
30522            &broad_file,
30523            "alpha helper alpha helper alpha helper in prose\n",
30524        )
30525        .unwrap();
30526
30527        let mut response = empty_search_response(dir.path(), "lexical");
30528        response.hits.push(test_lexical_search_hit(
30529            &broad_file,
30530            1,
30531            240.0,
30532            "alpha helper alpha helper alpha helper in prose",
30533        ));
30534        let symbol_hits = vec![index::SymbolHit {
30535            name: "alpha_helper".to_string(),
30536            kind: "function".to_string(),
30537            language: "rust".to_string(),
30538            file: file.to_string_lossy().to_string(),
30539            line: 0,
30540            end_line: Some(0),
30541            node_kind: Some("function_item".to_string()),
30542            start_byte: Some(0),
30543            end_byte: Some(i64::try_from(source.len()).unwrap()),
30544            body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30545            body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30546            tags: Some("alpha,helper".to_string()),
30547            score: 0.8,
30548            match_type: "all_tags".to_string(),
30549            tagpath_handle: None,
30550        }];
30551
30552        let report = build_relative_search_budget_report(
30553            "alpha helper",
30554            "lexical",
30555            dir.path(),
30556            &response,
30557            &symbol_hits,
30558            ResponseBudget::new(Some(5), Some(128)),
30559            &SearchFacetFilters::default(),
30560        );
30561
30562        assert_eq!(report.ranked[0].source, "symbol_span");
30563        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30564        assert!(report.ranked[0].score > report.ranked[1].score);
30565        assert_eq!(report.ranked[1].source, "lexical_file");
30566    }
30567
30568    #[test]
30569    fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
30570        let dir = tempfile::tempdir().unwrap();
30571        let src_dir = dir.path().join("src");
30572        fs::create_dir_all(&src_dir).unwrap();
30573        let source = "fn alpha_helper() {\n    let needle = \"needle\";\n}\n\nfn other() {}\n";
30574        let file = src_dir.join("lib.rs");
30575        fs::write(&file, source).unwrap();
30576
30577        let mut response = empty_search_response(dir.path(), "exact");
30578        let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
30579        hit.location = Some("line 2".to_string());
30580        response.hits.push(hit);
30581
30582        let symbol_hits = vec![index::SymbolHit {
30583            name: "alpha_helper".to_string(),
30584            kind: "function".to_string(),
30585            language: "rust".to_string(),
30586            file: file.to_string_lossy().to_string(),
30587            line: 0,
30588            end_line: Some(2),
30589            node_kind: Some("function_item".to_string()),
30590            start_byte: Some(0),
30591            end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
30592            body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
30593            body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
30594            tags: Some("alpha,helper".to_string()),
30595            score: 0.9,
30596            match_type: "all_tags".to_string(),
30597            tagpath_handle: None,
30598        }];
30599
30600        let report = build_relative_search_budget_report(
30601            "needle",
30602            "exact",
30603            dir.path(),
30604            &response,
30605            &symbol_hits,
30606            ResponseBudget::new(Some(5), Some(128)),
30607            &SearchFacetFilters::default(),
30608        );
30609
30610        let hit = &report.hits[0];
30611        assert_eq!(hit.line, Some(2));
30612        let source_handle = hit
30613            .source_handle
30614            .as_ref()
30615            .expect("exact hit should expose a bounded source_handle window");
30616        assert!(source_handle.handle.starts_with("xwin-"));
30617        assert_eq!(source_handle.kind, "source_handle");
30618        assert_eq!(source_handle.file, "src/lib.rs");
30619        assert_eq!(source_handle.start_line, 1);
30620        assert_eq!(source_handle.end_line, 3);
30621        assert!(source_handle.expand.contains("source-read"));
30622
30623        let containing_symbol = hit
30624            .containing_symbol
30625            .as_ref()
30626            .expect("exact hit should expose its containing symbol when indexed");
30627        assert_eq!(containing_symbol.name, "alpha_helper");
30628        assert_eq!(containing_symbol.kind, "function");
30629        assert_eq!(containing_symbol.line, 1);
30630        assert_eq!(containing_symbol.end_line, Some(3));
30631        assert!(containing_symbol.expand.contains("symbol-read"));
30632
30633        let lexical_rank = report
30634            .ranked
30635            .iter()
30636            .find(|item| item.source == "lexical_file")
30637            .expect("ranked preview should retain the lexical retrieval handle");
30638        assert!(
30639            lexical_rank
30640                .reasons
30641                .iter()
30642                .any(|reason| reason == "source_handle")
30643        );
30644        assert!(
30645            lexical_rank
30646                .reasons
30647                .iter()
30648                .any(|reason| reason == "containing_symbol")
30649        );
30650    }
30651
30652    #[test]
30653    fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
30654        let dir = tempfile::tempdir().unwrap();
30655        let src_dir = dir.path().join("src");
30656        let tests_dir = dir.path().join("tests");
30657        fs::create_dir_all(&src_dir).unwrap();
30658        fs::create_dir_all(&tests_dir).unwrap();
30659        let source_file = src_dir.join("lib.rs");
30660        let test_file = tests_dir.join("alpha_test.rs");
30661        fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
30662        fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
30663
30664        let response = empty_search_response(dir.path(), "lexical");
30665        let symbol_hits = vec![
30666            index::SymbolHit {
30667                name: "alpha_helper_test".to_string(),
30668                kind: "function".to_string(),
30669                language: "rust".to_string(),
30670                file: test_file.to_string_lossy().to_string(),
30671                line: 1,
30672                end_line: Some(1),
30673                node_kind: Some("function_item".to_string()),
30674                start_byte: Some(8),
30675                end_byte: Some(33),
30676                body_start_byte: Some(31),
30677                body_end_byte: Some(31),
30678                tags: Some("alpha,helper,test".to_string()),
30679                score: 1.0,
30680                match_type: "exact_name".to_string(),
30681                tagpath_handle: None,
30682            },
30683            index::SymbolHit {
30684                name: "alpha_helper".to_string(),
30685                kind: "function".to_string(),
30686                language: "rust".to_string(),
30687                file: source_file.to_string_lossy().to_string(),
30688                line: 0,
30689                end_line: Some(0),
30690                node_kind: Some("function_item".to_string()),
30691                start_byte: Some(0),
30692                end_byte: Some(20),
30693                body_start_byte: Some(18),
30694                body_end_byte: Some(18),
30695                tags: Some("alpha,helper".to_string()),
30696                score: 0.78,
30697                match_type: "all_tags".to_string(),
30698                tagpath_handle: None,
30699            },
30700        ];
30701
30702        let report = build_relative_search_budget_report(
30703            "alpha helper",
30704            "lexical",
30705            dir.path(),
30706            &response,
30707            &symbol_hits,
30708            ResponseBudget::new(Some(5), Some(128)),
30709            &SearchFacetFilters::default(),
30710        );
30711
30712        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30713        assert_eq!(report.ranked[0].path, "src/lib.rs");
30714        assert!(
30715            report.ranked[0]
30716                .reasons
30717                .iter()
30718                .any(|reason| reason == "definition_kind")
30719        );
30720        assert!(
30721            report.ranked[0]
30722                .reasons
30723                .iter()
30724                .any(|reason| reason == "source_path")
30725        );
30726        let test_rank = report
30727            .ranked
30728            .iter()
30729            .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
30730            .expect("test symbol should still be present in the ranked preview");
30731        assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
30732    }
30733
30734    #[test]
30735    fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
30736        let dir = tempfile::tempdir().unwrap();
30737        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30738        let file = dir.path().join("README.md");
30739        fs::write(&file, source).unwrap();
30740        let summary_db =
30741            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
30742        summary_db
30743            .insert(&test_summary(
30744                "rust",
30745                "README.md",
30746                "Rust fence contains a sample function.",
30747            ))
30748            .unwrap();
30749
30750        let fence_start = source.find("```rust").unwrap();
30751        let body_start = source.find("fn sample").unwrap();
30752        let body_end = body_start + "fn sample() {}\n".len();
30753        let response = empty_search_response(dir.path(), "lexical");
30754        let symbol_hits = vec![index::SymbolHit {
30755            name: "rust".to_string(),
30756            kind: "code_block".to_string(),
30757            language: "markdown".to_string(),
30758            file: file.to_string_lossy().to_string(),
30759            line: 2,
30760            end_line: Some(4),
30761            node_kind: Some("fenced_code_block".to_string()),
30762            start_byte: Some(i64::try_from(fence_start).unwrap()),
30763            end_byte: Some(i64::try_from(source.len()).unwrap()),
30764            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30765            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30766            tags: Some("rust".to_string()),
30767            score: 1.0,
30768            match_type: "exact_name".to_string(),
30769            tagpath_handle: None,
30770        }];
30771
30772        let report = build_relative_search_budget_report(
30773            "rust",
30774            "lexical",
30775            dir.path(),
30776            &response,
30777            &symbol_hits,
30778            ResponseBudget::new(Some(5), Some(128)),
30779            &SearchFacetFilters::default(),
30780        );
30781
30782        let symbol = &report.symbols[0];
30783        assert_eq!(symbol.summary_refs, 1);
30784        assert_eq!(symbol.graph_neighbors, 1);
30785        assert!(
30786            report.ranked[0]
30787                .reasons
30788                .iter()
30789                .any(|reason| reason == "summary_refs:1")
30790        );
30791        assert!(
30792            report.ranked[0]
30793                .reasons
30794                .iter()
30795                .any(|reason| reason == "graph_neighbors:1")
30796        );
30797    }
30798
30799    fn markdown_search_facet_fixture() -> tempfile::TempDir {
30800        let dir = tempfile::tempdir().unwrap();
30801        let source = r#"# Guide
30802
30803## Install
30804
30805- Run setup.
30806  - Confirm setup.
30807
30808```rust
30809fn sample() {}
30810```
30811"#;
30812        fs::write(dir.path().join("README.md"), source).unwrap();
30813        let index_dir = dir.path().join(".tsift");
30814        fs::create_dir_all(&index_dir).unwrap();
30815        run_index_update(
30816            &index_dir.join("index.db"),
30817            dir.path(),
30818            "indexing markdown search facet fixture".to_string(),
30819            dir.path(),
30820            None,
30821            false,
30822            false,
30823        )
30824        .unwrap();
30825        dir
30826    }
30827
30828    fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
30829        let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
30830        db.symbol_search(query, 20).unwrap()
30831    }
30832
30833    #[test]
30834    fn search_facet_filters_match_scalar_symbol_fields() {
30835        let dir = tempfile::tempdir().unwrap();
30836        let hits = vec![
30837            index::SymbolHit {
30838                name: "alpha_helper".to_string(),
30839                kind: "function".to_string(),
30840                language: "rust".to_string(),
30841                file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
30842                line: 0,
30843                end_line: None,
30844                node_kind: Some("function_item".to_string()),
30845                start_byte: None,
30846                end_byte: None,
30847                body_start_byte: None,
30848                body_end_byte: None,
30849                tags: None,
30850                score: 1.0,
30851                match_type: "exact_name".to_string(),
30852                tagpath_handle: None,
30853            },
30854            index::SymbolHit {
30855                name: "Install".to_string(),
30856                kind: "heading".to_string(),
30857                language: "markdown".to_string(),
30858                file: dir.path().join("README.md").to_string_lossy().to_string(),
30859                line: 0,
30860                end_line: None,
30861                node_kind: Some("atx_heading".to_string()),
30862                start_byte: None,
30863                end_byte: None,
30864                body_start_byte: None,
30865                body_end_byte: None,
30866                tags: None,
30867                score: 0.9,
30868                match_type: "exact_name".to_string(),
30869                tagpath_handle: None,
30870            },
30871        ];
30872
30873        let filtered = apply_search_facet_filters(
30874            dir.path(),
30875            hits,
30876            &SearchFacetFilters {
30877                languages: vec!["rust".to_string()],
30878                kinds: vec!["function".to_string()],
30879                node_kinds: vec!["function_item".to_string()],
30880                ..SearchFacetFilters::default()
30881            },
30882        );
30883
30884        assert_eq!(filtered.len(), 1);
30885        assert_eq!(filtered[0].name, "alpha_helper");
30886    }
30887
30888    #[test]
30889    fn search_facet_filters_match_markdown_sections_and_block_metadata() {
30890        let dir = markdown_search_facet_fixture();
30891
30892        let nested_list = apply_search_facet_filters(
30893            dir.path(),
30894            markdown_search_facet_hits(dir.path(), "setup"),
30895            &SearchFacetFilters {
30896                sections: vec!["Install".to_string()],
30897                parents: vec!["Run setup.".to_string()],
30898                list_depths: vec![1],
30899                ..SearchFacetFilters::default()
30900            },
30901        );
30902        assert_eq!(nested_list.len(), 1);
30903        assert_eq!(nested_list[0].name, "Confirm setup.");
30904
30905        let parent_list = apply_search_facet_filters(
30906            dir.path(),
30907            markdown_search_facet_hits(dir.path(), "setup"),
30908            &SearchFacetFilters {
30909                children: vec!["Confirm setup.".to_string()],
30910                ..SearchFacetFilters::default()
30911            },
30912        );
30913        assert_eq!(parent_list.len(), 1);
30914        assert_eq!(parent_list[0].name, "Run setup.");
30915
30916        let heading = apply_search_facet_filters(
30917            dir.path(),
30918            markdown_search_facet_hits(dir.path(), "Install"),
30919            &SearchFacetFilters {
30920                heading_levels: vec![2],
30921                node_kinds: vec!["atx_heading".to_string()],
30922                ..SearchFacetFilters::default()
30923            },
30924        );
30925        assert_eq!(heading.len(), 1);
30926        assert_eq!(heading[0].name, "Install");
30927
30928        let fence = apply_search_facet_filters(
30929            dir.path(),
30930            markdown_search_facet_hits(dir.path(), "rust"),
30931            &SearchFacetFilters {
30932                fence_languages: vec!["rust".to_string()],
30933                kinds: vec!["code_block".to_string()],
30934                ..SearchFacetFilters::default()
30935            },
30936        );
30937        assert_eq!(fence.len(), 1);
30938        assert_eq!(fence[0].kind, "code_block");
30939
30940        let embedded_child = apply_search_facet_filters(
30941            dir.path(),
30942            markdown_search_facet_hits(dir.path(), "rust"),
30943            &SearchFacetFilters {
30944                children: vec!["sample".to_string()],
30945                kinds: vec!["code_block".to_string()],
30946                ..SearchFacetFilters::default()
30947            },
30948        );
30949        assert_eq!(embedded_child.len(), 1);
30950        assert_eq!(embedded_child[0].name, "rust");
30951    }
30952
30953    #[test]
30954    fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
30955        let response = empty_search_response(Path::new("/repo"), "lexical");
30956        let symbol_hits = vec![
30957            index::SymbolHit {
30958                name: "alpha_helper".to_string(),
30959                kind: "function".to_string(),
30960                language: "rust".to_string(),
30961                file: "/repo/src/lib.rs".to_string(),
30962                line: 12,
30963                end_line: None,
30964                node_kind: None,
30965                start_byte: None,
30966                end_byte: None,
30967                body_start_byte: None,
30968                body_end_byte: None,
30969                tags: Some("alpha,helper".to_string()),
30970                score: 0.98,
30971                match_type: "exact_name".to_string(),
30972                tagpath_handle: None,
30973            },
30974            index::SymbolHit {
30975                name: "alphaHelper".to_string(),
30976                kind: "method".to_string(),
30977                language: "rust".to_string(),
30978                file: "/repo/src/main.rs".to_string(),
30979                line: 34,
30980                end_line: None,
30981                node_kind: None,
30982                start_byte: None,
30983                end_byte: None,
30984                body_start_byte: None,
30985                body_end_byte: None,
30986                tags: Some("alpha,helper".to_string()),
30987                score: 0.93,
30988                match_type: "tag_overlap".to_string(),
30989                tagpath_handle: None,
30990            },
30991            index::SymbolHit {
30992                name: "alpha_helper".to_string(),
30993                kind: "function".to_string(),
30994                language: "rust".to_string(),
30995                file: "/repo/src/worker.rs".to_string(),
30996                line: 56,
30997                end_line: None,
30998                node_kind: None,
30999                start_byte: None,
31000                end_byte: None,
31001                body_start_byte: None,
31002                body_end_byte: None,
31003                tags: Some("alpha,helper".to_string()),
31004                score: 0.91,
31005                match_type: "tag_overlap".to_string(),
31006                tagpath_handle: None,
31007            },
31008        ];
31009
31010        let report = build_relative_search_budget_report(
31011            "alpha helper",
31012            "lexical",
31013            Path::new("/repo"),
31014            &response,
31015            &symbol_hits,
31016            ResponseBudget::new(Some(5), Some(48)),
31017            &SearchFacetFilters::default(),
31018        );
31019
31020        assert_eq!(report.symbol_total, 1);
31021        assert_eq!(report.raw_symbol_total, 3);
31022        assert_eq!(report.symbols.len(), 1);
31023        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
31024        assert_eq!(report.symbols[0].match_count, 3);
31025        assert_eq!(report.symbols[0].surface_count, 2);
31026        assert_eq!(report.symbols[0].file_count, 3);
31027        assert_eq!(
31028            report.symbols[0].surface_examples,
31029            vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
31030        );
31031        assert!(report.symbols[0].name.contains("(+1 variant)"));
31032        assert!(report.symbols[0].file.contains("(+2 files)"));
31033        assert!(report.symbols[0].expand.contains("tsift search"));
31034        assert!(report.symbols[0].expand.contains("alpha helper"));
31035    }
31036
31037    #[test]
31038    fn search_budget_report_carries_active_filters() {
31039        let response = empty_search_response(Path::new("/repo"), "lexical");
31040        let symbol_hits = vec![index::SymbolHit {
31041            name: "alpha_helper".to_string(),
31042            kind: "function".to_string(),
31043            language: "rust".to_string(),
31044            file: "/repo/src/lib.rs".to_string(),
31045            line: 12,
31046            end_line: None,
31047            node_kind: Some("function_item".to_string()),
31048            start_byte: None,
31049            end_byte: None,
31050            body_start_byte: None,
31051            body_end_byte: None,
31052            tags: Some("alpha,helper".to_string()),
31053            score: 0.98,
31054            match_type: "exact_name".to_string(),
31055            tagpath_handle: None,
31056        }];
31057        let filters = SearchFacetFilters {
31058            languages: vec!["rust".to_string()],
31059            kinds: vec!["function".to_string()],
31060            node_kinds: vec!["function_item".to_string()],
31061            ..SearchFacetFilters::default()
31062        };
31063
31064        let report = build_relative_search_budget_report(
31065            "alpha helper",
31066            "lexical",
31067            Path::new("/repo"),
31068            &response,
31069            &symbol_hits,
31070            ResponseBudget::new(Some(5), Some(48)),
31071            &filters,
31072        );
31073
31074        assert_eq!(report.filters, filters);
31075        assert_eq!(
31076            search_facet_filters_summary(&report.filters),
31077            "lang=rust kind=function node-kind=function_item"
31078        );
31079    }
31080
31081    #[test]
31082    fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
31083        let mut response = empty_search_response(Path::new("/repo"), "lexical");
31084        response.indexed_artifacts = 450;
31085        let symbol_hits = vec![
31086            index::SymbolHit {
31087                name: "alpha_helper".to_string(),
31088                kind: "function".to_string(),
31089                language: "rust".to_string(),
31090                file: "/repo/src/lib.rs".to_string(),
31091                line: 12,
31092                end_line: None,
31093                node_kind: None,
31094                start_byte: None,
31095                end_byte: None,
31096                body_start_byte: None,
31097                body_end_byte: None,
31098                tags: Some("alpha,helper".to_string()),
31099                score: 0.98,
31100                match_type: "exact_name".to_string(),
31101                tagpath_handle: None,
31102            },
31103            index::SymbolHit {
31104                name: "beta_helper".to_string(),
31105                kind: "function".to_string(),
31106                language: "rust".to_string(),
31107                file: "/repo/src/beta.rs".to_string(),
31108                line: 21,
31109                end_line: None,
31110                node_kind: None,
31111                start_byte: None,
31112                end_byte: None,
31113                body_start_byte: None,
31114                body_end_byte: None,
31115                tags: Some("beta,helper".to_string()),
31116                score: 0.92,
31117                match_type: "tag_overlap".to_string(),
31118                tagpath_handle: None,
31119            },
31120        ];
31121
31122        let report = build_relative_search_budget_report(
31123            "helper",
31124            "lexical",
31125            Path::new("/repo"),
31126            &response,
31127            &symbol_hits,
31128            ResponseBudget::new(Some(1), Some(64)),
31129            &SearchFacetFilters::default(),
31130        );
31131
31132        let guard = report
31133            .scale_guard
31134            .as_ref()
31135            .expect("broad previews should emit a scale guard");
31136        assert_eq!(guard.level, "high-hit");
31137        assert_eq!(guard.signals.indexed_artifacts, 450);
31138        assert_eq!(guard.signals.raw_symbol_matches, 2);
31139        assert!(
31140            guard
31141                .narrow_commands
31142                .iter()
31143                .any(|command| command.contains("--exact"))
31144        );
31145        assert!(
31146            guard
31147                .narrow_commands
31148                .iter()
31149                .any(|command| command.contains("alpha helper"))
31150        );
31151        assert!(
31152            guard
31153                .narrow_commands
31154                .last()
31155                .unwrap()
31156                .contains("workflow search")
31157        );
31158    }
31159
31160    #[test]
31161    fn explain_budget_report_limits_edges_and_members() {
31162        let symbols = vec![index::StoredSymbol {
31163            name: "alpha_helper".to_string(),
31164            kind: "function".to_string(),
31165            language: "rust".to_string(),
31166            signature: None,
31167            file: "src/lib.rs".to_string(),
31168            line: 10,
31169            end_line: None,
31170            node_kind: None,
31171            start_byte: None,
31172            end_byte: None,
31173            body_start_byte: None,
31174            body_end_byte: None,
31175            parent_module: None,
31176            visibility: None,
31177            tags: None,
31178            tagpath_handle: None,
31179        }];
31180        let callers = vec![
31181            index::StoredEdge {
31182                caller_file: "src/main.rs".to_string(),
31183                caller_name: "main".to_string(),
31184                caller_line: 1,
31185                callee_name: "alpha_helper".to_string(),
31186                call_site_line: 3,
31187                tagpath_handle: None,
31188            },
31189            index::StoredEdge {
31190                caller_file: "src/worker.rs".to_string(),
31191                caller_name: "worker".to_string(),
31192                caller_line: 5,
31193                callee_name: "alpha_helper".to_string(),
31194                call_site_line: 8,
31195                tagpath_handle: None,
31196            },
31197        ];
31198        let community = graph::Community {
31199            id: 1,
31200            members: vec![
31201                graph::CommunityMember::new("alpha_helper"),
31202                graph::CommunityMember::new("main"),
31203                graph::CommunityMember::new("worker"),
31204            ],
31205            modularity_contribution: 0.5,
31206        };
31207
31208        let report = build_explain_budget_report(
31209            "alpha_helper",
31210            Path::new("/repo"),
31211            &symbols,
31212            &callers,
31213            2,
31214            false,
31215            &[],
31216            0,
31217            false,
31218            Some(&community),
31219            ResponseBudget::new(Some(1), Some(24)),
31220        );
31221
31222        assert_eq!(report.definitions.len(), 1);
31223        assert_eq!(report.callers.len(), 1);
31224        assert!(report.truncated);
31225        assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
31226        assert_eq!(
31227            report.definitions[0].tag_alias.as_deref(),
31228            Some("alpha/helper")
31229        );
31230        assert!(report.callers[0].handle.starts_with("ecall-"));
31231        assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
31232    }
31233
31234    #[test]
31235    fn session_review_next_context_budget_limits_lists() {
31236        let report = session_review::SessionReviewReport {
31237            root: "/repo".to_string(),
31238            target: "tasks/software/tsift.md".to_string(),
31239            target_kind: "file".to_string(),
31240            sessions_considered: 1,
31241            sessions_matched: 1,
31242            claude_sessions: 1,
31243            codex_sessions: 0,
31244            agent_doc_logs: 0,
31245            prompt_target_count: 2,
31246            command_groups: 0,
31247            file_groups: 2,
31248            symbol_groups: 1,
31249            failure_groups: 1,
31250            runtime_event_groups: 0,
31251            restart_churn_groups: 0,
31252            closeout_groups: 0,
31253            usage_samples: 1,
31254            prompt_tokens: 120,
31255            cached_input_tokens: 80,
31256            cache_creation_input_tokens: 0,
31257            output_tokens: 40,
31258            reasoning_output_tokens: 0,
31259            total_tokens: 240,
31260            cached_input_ratio: Some(40.0),
31261            largest_turn_total_tokens: 240,
31262            aggregate_cost: session_review::SessionReviewCostSummary {
31263                scope: "bounded_matched_sessions".to_string(),
31264                sessions: 1,
31265                usage_samples: 1,
31266                prompt_tokens: 120,
31267                cached_input_tokens: 80,
31268                cache_creation_input_tokens: 0,
31269                output_tokens: 40,
31270                reasoning_output_tokens: 0,
31271                total_tokens: 240,
31272                cached_input_ratio: Some(40.0),
31273                largest_turn_total_tokens: 240,
31274            },
31275            latest_session_cost: Some(session_review::SessionReviewCostSummary {
31276                scope: "latest_matched_session".to_string(),
31277                sessions: 1,
31278                usage_samples: 1,
31279                prompt_tokens: 120,
31280                cached_input_tokens: 80,
31281                cache_creation_input_tokens: 0,
31282                output_tokens: 40,
31283                reasoning_output_tokens: 0,
31284                total_tokens: 240,
31285                cached_input_ratio: Some(66.67),
31286                largest_turn_total_tokens: 240,
31287            }),
31288            prompt_cache_cross_run: None,
31289            prompt_cache_roi_scorecard: vec![],
31290            guardrails: vec![
31291                session_cost::SessionCostGuardrail {
31292                    kind: "cache_resend".to_string(),
31293                    severity: "warn".to_string(),
31294                    message: "cached input ratio was high".to_string(),
31295                    guidance: "compact or restart the session".to_string(),
31296                },
31297                session_cost::SessionCostGuardrail {
31298                    kind: "prompt_budget".to_string(),
31299                    severity: "warn".to_string(),
31300                    message: "largest prompt turn reached 999999 tokens".to_string(),
31301                    guidance: "compact the session before another large turn".to_string(),
31302                },
31303                session_cost::SessionCostGuardrail {
31304                    kind: "restart_loop".to_string(),
31305                    severity: "warn".to_string(),
31306                    message: "restart churn detected".to_string(),
31307                    guidance: "restart cleanly".to_string(),
31308                },
31309                session_cost::SessionCostGuardrail {
31310                    kind: "noop_closeout".to_string(),
31311                    severity: "warn".to_string(),
31312                    message: "commit_already_current appeared 8 times".to_string(),
31313                    guidance: "avoid reopening without new edits".to_string(),
31314                },
31315            ],
31316            loop_clusters: vec![session_cost::SessionCostLoopCluster {
31317                kind: "command_bundle".to_string(),
31318                label: "cargo test -> cargo build --release".to_string(),
31319                occurrences: 2,
31320                max_consecutive: 2,
31321            }],
31322            file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
31323                path: "src/lib.rs".to_string(),
31324                range: "12-40".to_string(),
31325                occurrences: 3,
31326                estimated_tokens: 1200,
31327                duplicate_estimated_tokens: 800,
31328                follow_up_commands: vec![
31329                    "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
31330                        .to_string(),
31331                ],
31332            }],
31333            prompt_targets: vec![
31334                session_review::SessionReviewPromptTarget {
31335                    text: "do one".to_string(),
31336                    occurrences: 1,
31337                },
31338                session_review::SessionReviewPromptTarget {
31339                    text: "do two".to_string(),
31340                    occurrences: 1,
31341                },
31342            ],
31343            commands: vec![],
31344            touched_files: vec![],
31345            touched_symbols: vec![],
31346            failures: vec![],
31347            runtime_events: vec![],
31348            restart_churn: vec![],
31349            closeout: vec![],
31350            largest_turns: vec![],
31351            sessions: vec![session_review::SessionReviewSession {
31352                source: "claude_jsonl".to_string(),
31353                path: "/tmp/session.jsonl".to_string(),
31354                matched_by: vec!["path".to_string()],
31355                modified_unix_secs: None,
31356                prompt_target_count: 2,
31357                command_groups: 0,
31358                file_groups: 2,
31359                symbol_groups: 1,
31360                failure_groups: 1,
31361                runtime_event_groups: 0,
31362                restart_churn_groups: 0,
31363                closeout_groups: 0,
31364                usage_samples: 1,
31365                prompt_tokens: 120,
31366                cached_input_tokens: 80,
31367                cache_creation_input_tokens: 0,
31368                output_tokens: 40,
31369                reasoning_output_tokens: 0,
31370                total_tokens: 240,
31371                largest_turn_total_tokens: 240,
31372            }],
31373            next_context: session_review::SessionReviewNextContext {
31374                target: "tasks/software/tsift.md".to_string(),
31375                active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
31376                last_verification: session_review::SessionReviewVerificationState {
31377                    status: "green".to_string(),
31378                    detail: "cargo test".to_string(),
31379                },
31380                touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
31381                touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
31382                unresolved_failures: vec![session_review::SessionReviewFailure {
31383                    kind: "timeout".to_string(),
31384                    message: "search timed out".to_string(),
31385                    occurrences: 1,
31386                    command: None,
31387                    session_path: None,
31388                }],
31389                agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
31390                    active_queue_prompt: Some(
31391                        "[#one] do one with enough detail to truncate".to_string(),
31392                    ),
31393                    live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
31394                    backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
31395                    review_rows: vec![
31396                        "[#review] review one".to_string(),
31397                        "[#review2] review two".to_string(),
31398                    ],
31399                    prompt_presets: vec![
31400                        "#spec-test-build-install-commit-push: update spec + tests"
31401                            .to_string(),
31402                        "#next-steps: collect follow-ups".to_string(),
31403                    ],
31404                    expansion_handles: vec![
31405                        session_review::SessionReviewAgentDocExpansionHandle {
31406                            handle: "adq-next-context".to_string(),
31407                            label: "refresh next-context".to_string(),
31408                            expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
31409                        },
31410                        session_review::SessionReviewAgentDocExpansionHandle {
31411                            handle: "adq-context-pack".to_string(),
31412                            label: "refresh context-pack".to_string(),
31413                            expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
31414                        },
31415                    ],
31416                }),
31417                prompt_cache_health: None,
31418                next_digest_commands: vec![
31419                    "tsift session-review --next-context tasks/software/tsift.md".to_string(),
31420                    "tsift diff-digest .".to_string(),
31421                    "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
31422                    "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
31423                ],
31424            },
31425            warnings: vec![],
31426        };
31427
31428        let budget_report = build_session_review_next_context_budget_report(
31429            &report,
31430            ResponseBudget::new(Some(1), Some(12)),
31431            None,
31432        );
31433
31434        assert!(budget_report.truncated);
31435        assert_eq!(budget_report.prompt_targets, vec!["do one"]);
31436        assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
31437        assert!(
31438            budget_report.touched_symbol_refs[0]
31439                .handle
31440                .starts_with("ncsym-")
31441        );
31442        assert_eq!(
31443            budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
31444            Some("alpha/helper")
31445        );
31446        assert!(
31447            budget_report.unresolved_failures[0]
31448                .handle
31449                .starts_with("snf-")
31450        );
31451        assert_eq!(budget_report.next_digest_commands.len(), 4);
31452        assert_eq!(
31453            budget_report.next_digest_commands[2],
31454            "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
31455        );
31456        let queue = budget_report
31457            .agent_doc_queue
31458            .as_ref()
31459            .expect("agent-doc queue budget profile should be present");
31460        assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
31461        assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
31462        assert_eq!(queue.review_row_total, 2);
31463        assert_eq!(queue.prompt_presets.len(), 1);
31464        assert_eq!(queue.expansion_handles.len(), 2);
31465        assert!(queue.truncated);
31466        assert_eq!(budget_report.next_token_actions.len(), 1);
31467        assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
31468
31469        let full_action_report = build_session_review_next_context_budget_report(
31470            &report,
31471            ResponseBudget::new(Some(6), Some(120)),
31472            None,
31473        );
31474        assert_eq!(
31475            full_action_report
31476                .next_token_actions
31477                .iter()
31478                .map(|action| action.kind.as_str())
31479                .collect::<Vec<_>>(),
31480            vec![
31481                "prompt_budget",
31482                "cache_resend",
31483                "repeated_raw_read",
31484                "repeated_command_bundle",
31485                "restart_loop",
31486                "noop_closeout"
31487            ]
31488        );
31489        assert_eq!(
31490            full_action_report.next_token_actions[0]
31491                .compact_command
31492                .as_deref(),
31493            Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
31494        );
31495        assert_eq!(
31496            full_action_report.next_token_actions[0]
31497                .restart_command
31498                .as_deref(),
31499            Some("agent-doc start \"tasks/software/tsift.md\"")
31500        );
31501        assert!(
31502            full_action_report.next_token_actions[0]
31503                .digest_commands
31504                .iter()
31505                .any(|command| command
31506                    == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
31507        );
31508        let raw_read_action = full_action_report
31509            .next_token_actions
31510            .iter()
31511            .find(|action| action.kind == "repeated_raw_read")
31512            .expect("raw read action");
31513        assert!(
31514            raw_read_action.rewrite_commands.iter().any(
31515                |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
31516            ),
31517            "raw read rewrite commands: {:?}",
31518            raw_read_action.rewrite_commands
31519        );
31520        assert!(raw_read_action.rewrite_commands.iter().any(|command| command
31521        == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
31522        let command_bundle_action = full_action_report
31523            .next_token_actions
31524            .iter()
31525            .find(|action| action.kind == "repeated_command_bundle")
31526            .expect("command bundle action");
31527        assert!(
31528            command_bundle_action
31529                .rewrite_commands
31530                .iter()
31531                .any(|command| command == "tsift rewrite --run \"cargo test\"")
31532        );
31533        assert!(
31534            command_bundle_action
31535                .rewrite_commands
31536                .iter()
31537                .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
31538        );
31539    }
31540
31541    #[test]
31542    fn context_pack_diff_preview_limits_files_and_symbols() {
31543        let report = diff_digest::DiffDigestReport {
31544            root: "/repo".to_string(),
31545            mode: diff_digest::DiffDigestMode::WorkingTree,
31546            revision: None,
31547            files_changed: 2,
31548            files_with_current_summaries: 1,
31549            symbols_touched: 3,
31550            call_edges_added: 1,
31551            call_edges_removed: 0,
31552            files: vec![
31553                diff_digest::DiffDigestFile {
31554                    path: "src/lib.rs".to_string(),
31555                    status: diff_digest::DiffDigestFileStatus::Modified,
31556                    touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
31557                    summary_state: diff_digest::DiffDigestSummaryState::Current,
31558                    current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31559                        symbol: "alpha_helper".to_string(),
31560                        summary: "alpha helper handles the main alpha workflow".to_string(),
31561                    }],
31562                    added_call_edges: vec!["alpha->beta".to_string()],
31563                    removed_call_edges: vec![],
31564                    warnings: vec!["stale parse".to_string()],
31565                },
31566                diff_digest::DiffDigestFile {
31567                    path: "src/main.rs".to_string(),
31568                    status: diff_digest::DiffDigestFileStatus::Added,
31569                    touched_symbols: vec!["main".to_string()],
31570                    summary_state: diff_digest::DiffDigestSummaryState::Missing,
31571                    current_summaries: vec![],
31572                    added_call_edges: vec![],
31573                    removed_call_edges: vec![],
31574                    warnings: vec![],
31575                },
31576            ],
31577        };
31578
31579        let preview =
31580            build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
31581
31582        assert!(preview.truncated);
31583        assert_eq!(preview.files.len(), 1);
31584        assert_eq!(preview.files[0].path, "src/lib.rs");
31585        assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
31586        assert!(
31587            preview.files[0].touched_symbol_refs[0]
31588                .handle
31589                .starts_with("cdsym-")
31590        );
31591        assert_eq!(
31592            preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
31593            Some("alpha/he...")
31594        );
31595        assert!(
31596            preview.files[0].summary_refs[0]
31597                .handle
31598                .starts_with("cdsum-")
31599        );
31600        assert_eq!(
31601            preview.files[0].summary_refs[0].tag_alias.as_deref(),
31602            Some("alpha/he...")
31603        );
31604        assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
31605        assert_eq!(
31606            preview.files[0].summary_refs[0].expand,
31607            "tsift summarize --file \"src/lib.rs\""
31608        );
31609        assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
31610    }
31611
31612    #[test]
31613    fn context_pack_status_reminders_include_stale_index_state() {
31614        let dir = setup_graph_index();
31615        std::thread::sleep(std::time::Duration::from_millis(50));
31616        std::fs::write(
31617            dir.path().join("main.rs"),
31618            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
31619        )
31620        .unwrap();
31621
31622        let reminders = context_pack_status_reminders(dir.path());
31623
31624        assert_eq!(reminders.len(), 1);
31625        assert!(reminders[0].contains("index stale"));
31626        assert!(reminders[0].contains("tsift index ."));
31627    }
31628
31629    // #gdbgatecold regression-lock: the trusted context-pack pipeline must
31630    // share its index-inspection across `prepare_agent_doc_index_gate` and
31631    // `context_pack_status_reminders` (both call `IndexDb::inspect_read_only`
31632    // on the same `(root, .tsift/index.db)` key). With the scope guard
31633    // active in `build_context_pack_report_with_profile`, the second call
31634    // hits the cache, so we should record one miss and at least one hit.
31635    #[test]
31636    fn build_context_pack_reuses_inspect_within_scope() {
31637        let dir = setup_graph_index();
31638        init_git_repo(dir.path());
31639        let _guard = index::InspectScopeGuard::new();
31640        let _ = build_context_pack_report(
31641            dir.path(),
31642            None,
31643            None,
31644            None,
31645            ResponseBudget::new(Some(2), Some(96)),
31646        )
31647        .unwrap();
31648        let (hits, misses) = index::inspect_scope_stats();
31649        assert!(
31650            hits >= 1,
31651            "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
31652        );
31653        assert!(
31654            misses >= 1,
31655            "expected at least one initial inspect miss (hits={hits}, misses={misses})"
31656        );
31657    }
31658
31659    // #gdbgatecold scope-isolation: outside of any scope, every call to
31660    // `IndexDb::inspect_read_only` must hit the disk fresh. This locks in
31661    // the contract that the search/status fast-paths never reuse a cached
31662    // inspection across consecutive top-level calls.
31663    #[test]
31664    fn inspect_read_only_outside_scope_does_not_cache() {
31665        let dir = setup_graph_index();
31666        let db_path = dir.path().join(".tsift/index.db");
31667        let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31668        let (hits, misses) = index::inspect_scope_stats();
31669        assert_eq!(
31670            (hits, misses),
31671            (0, 0),
31672            "no scope guard => no hits/misses recorded"
31673        );
31674        let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31675        let (hits, _) = index::inspect_scope_stats();
31676        assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
31677    }
31678
31679    #[test]
31680    fn context_pack_refreshes_stale_index_before_handoff() {
31681        let dir = setup_graph_index();
31682        init_git_repo(dir.path());
31683        std::thread::sleep(std::time::Duration::from_millis(50));
31684        std::fs::write(
31685            dir.path().join("main.rs"),
31686            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
31687        )
31688        .unwrap();
31689
31690        let report = build_context_pack_report(
31691            dir.path(),
31692            None,
31693            None,
31694            None,
31695            ResponseBudget::new(Some(2), Some(96)),
31696        )
31697        .unwrap();
31698
31699        assert!(
31700            report
31701                .status_reminders
31702                .iter()
31703                .any(|reminder| reminder.contains("index refreshed")
31704                    && reminder.contains("context-pack handoff")),
31705            "expected context-pack refresh diagnostic, got {:?}",
31706            report.status_reminders
31707        );
31708        assert!(
31709            !report
31710                .status_reminders
31711                .iter()
31712                .any(|reminder| reminder.contains("index stale")),
31713            "stale reminder should be gone after refresh: {:?}",
31714            report.status_reminders
31715        );
31716
31717        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
31718        let summary = db.compute_changes(dir.path()).unwrap();
31719        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
31720    }
31721
31722    #[test]
31723    fn context_pack_materializes_source_handles_into_graph_store() {
31724        let dir = tempfile::tempdir().unwrap();
31725        let packet = ExplorationPacket {
31726            budget: exploration_budget_for_counts(2, 1),
31727            relationship_map: vec![ExplorationRelation {
31728                from: "file:main.rs".to_string(),
31729                relation: "touches_symbol".to_string(),
31730                to: "symbol:helper".to_string(),
31731                label: Some("modified diff".to_string()),
31732            }],
31733            source_windows: vec![ExplorationSourceWindow {
31734                handle: "xwin-test".to_string(),
31735                file: "main.rs".to_string(),
31736                start: 1,
31737                end: 32,
31738                reason: "changed file".to_string(),
31739                expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
31740            }],
31741            worker_context: vec![ExplorationWorkerContext {
31742                handle: "xwrk-test".to_string(),
31743                target: "tasks/software/tsift.md".to_string(),
31744                summary: "do #kgnv".to_string(),
31745                expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
31746                    .to_string(),
31747            }],
31748            no_reread_guidance: "use windows".to_string(),
31749        };
31750
31751        let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
31752        assert_eq!(packet.source_windows[0].handle, "xwin-test");
31753
31754        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
31755        let source_handles = store.nodes_by_kind("source_handle").unwrap();
31756        assert_eq!(source_handles.len(), 1);
31757        assert_eq!(
31758            source_handles[0].properties.get("file"),
31759            Some(&"main.rs".to_string())
31760        );
31761        assert_eq!(
31762            store
31763                .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
31764                .unwrap()
31765                .len(),
31766            1
31767        );
31768        let worker_context = store.nodes_by_kind("worker_context").unwrap();
31769        assert_eq!(worker_context.len(), 1);
31770        assert_eq!(
31771            store
31772                .outgoing_edges("xwrk-test", Some("scopes_source"))
31773                .unwrap()
31774                .len(),
31775            1
31776        );
31777    }
31778
31779    #[test]
31780    fn context_pack_records_graph_orchestration_observability() {
31781        let dir = setup_traversal_project();
31782        init_git_repo(dir.path());
31783        let session = dir.path().join("tasks/software/tsift.md");
31784        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
31785
31786        let report = build_context_pack_report(
31787            &session,
31788            None,
31789            None,
31790            None,
31791            ResponseBudget::new(Some(4), Some(160)),
31792        )
31793        .unwrap();
31794
31795        assert_eq!(
31796            report.graph_orchestration.contract_version,
31797            CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
31798        );
31799        assert_eq!(
31800            report
31801                .graph_orchestration
31802                .projection_freshness
31803                .status
31804                .as_str(),
31805            "current"
31806        );
31807        assert!(!report.graph_orchestration.projection_hashes.is_empty());
31808        assert_eq!(report.graph_orchestration.readiness.status, "blocked");
31809        assert_eq!(
31810            report.graph_orchestration.readiness.reason,
31811            "summary_cache_empty"
31812        );
31813        assert!(report.graph_orchestration.readiness.fail_closed);
31814        assert!(
31815            report
31816                .graph_orchestration
31817                .readiness
31818                .next_commands
31819                .iter()
31820                .any(|command| command == "tsift summarize --extract ."),
31821            "{:?}",
31822            report.graph_orchestration.readiness.next_commands
31823        );
31824        assert!(
31825            report
31826                .graph_orchestration
31827                .evidence_packet_ids
31828                .iter()
31829                .all(|id| !id.starts_with("gevd-")),
31830            "evidence packet ids should be empty when readiness is blocked: {:?}",
31831            report.graph_orchestration.evidence_packet_ids
31832        );
31833        assert!(
31834            report
31835                .graph_orchestration
31836                .conflict_matrix_decisions
31837                .iter()
31838                .any(|decision| decision.contains("readiness blocked")),
31839            "conflict-matrix decisions should reference readiness block: {:?}",
31840            report.graph_orchestration.conflict_matrix_decisions
31841        );
31842        assert!(
31843            !report
31844                .graph_orchestration
31845                .follow_up_commands
31846                .iter()
31847                .any(|command| command.contains("conflict-matrix")),
31848            "conflict-matrix command should not appear when readiness is blocked: {:?}",
31849            report.graph_orchestration.follow_up_commands
31850        );
31851        assert!(
31852            report
31853                .graph_orchestration
31854                .follow_up_commands
31855                .iter()
31856                .any(|command| command == "tsift summarize --extract ."),
31857            "{:?}",
31858            report.graph_orchestration.follow_up_commands
31859        );
31860        assert!(
31861            !report
31862                .graph_orchestration
31863                .worker_ownership_blocks
31864                .is_empty()
31865        );
31866    }
31867
31868    #[test]
31869    fn convex_sync_report_chunks_upserts_and_tombstones() {
31870        let dir = setup_traversal_project();
31871        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
31872        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
31873        let mut snapshot = projection.to_convex_rows();
31874        snapshot.nodes.push(ConvexNodeRow {
31875            external_id: "stale-node".to_string(),
31876            kind: "backlog".to_string(),
31877            label: "stale".to_string(),
31878            properties: BTreeMap::new(),
31879            provenance: Vec::new(),
31880            freshness: None,
31881        });
31882        snapshot.edges.clear();
31883        snapshot.edges.push(ConvexEdgeRow {
31884            edge_key: "stale-edge".to_string(),
31885            from_external_id: "stale-node".to_string(),
31886            to_external_id: "stale-node".to_string(),
31887            kind: "mentions".to_string(),
31888            properties: BTreeMap::new(),
31889            provenance: Vec::new(),
31890            freshness: None,
31891        });
31892        let snapshot_path = dir.path().join("convex-snapshot.json");
31893        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31894
31895        let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
31896
31897        assert_eq!(report.freshness.status, "stale");
31898        assert!(report.freshness.fail_closed);
31899        assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
31900        assert!(
31901            report.edge_upserts.len() > 1,
31902            "snapshot without edges should upsert local edges"
31903        );
31904        assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
31905        assert_eq!(
31906            report.chunks.first().map(|chunk| chunk.operation.as_str()),
31907            Some("delete_edges"),
31908            "edge tombstones should be planned before node tombstones"
31909        );
31910        assert!(
31911            report
31912                .chunks
31913                .iter()
31914                .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
31915            "expected chunked edge upserts, got {:?}",
31916            report.chunks
31917        );
31918    }
31919
31920    #[test]
31921    fn convex_snapshot_validation_fails_closed_when_stale() {
31922        let dir = setup_traversal_project();
31923        build_traversal_graph(dir.path(), dir.path(), None).unwrap();
31924        let snapshot = ConvexProjectionRows::default();
31925        let snapshot_path = dir.path().join("empty-convex-snapshot.json");
31926        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31927
31928        let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
31929        assert!(
31930            err.to_string()
31931                .contains("Convex graph projection is not current"),
31932            "{err}"
31933        );
31934    }
31935
31936    #[test]
31937    fn convex_sync_report_marks_live_apply_mode_without_network() {
31938        let dir = setup_traversal_project();
31939        let report =
31940            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31941
31942        assert!(!report.dry_run);
31943        assert!(
31944            !report
31945                .diagnostics
31946                .iter()
31947                .any(|diagnostic| diagnostic.contains("dry-run only")),
31948            "apply-mode report should not claim dry-run diagnostics"
31949        );
31950        assert!(
31951            report
31952                .chunks
31953                .iter()
31954                .any(|chunk| chunk.operation == "upsert_nodes"),
31955            "live apply mode should still expose chunked idempotent operations"
31956        );
31957    }
31958
31959    #[test]
31960    fn convex_sync_apply_round_trips_with_http_backend() {
31961        use std::net::TcpListener;
31962        use std::sync::{Arc, Mutex};
31963
31964        let dir = setup_traversal_project();
31965        let report =
31966            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31967        let expected_chunks = report.chunks.len();
31968        assert!(expected_chunks > 0);
31969
31970        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
31971        let endpoint = format!("http://{}", listener.local_addr().unwrap());
31972        let operations = Arc::new(Mutex::new(Vec::<String>::new()));
31973        let server_operations = Arc::clone(&operations);
31974        let server = std::thread::spawn(move || {
31975            for _ in 0..expected_chunks {
31976                let (mut stream, _) = listener.accept().unwrap();
31977                let mut reader = BufReader::new(stream.try_clone().unwrap());
31978                let mut request_line = String::new();
31979                reader.read_line(&mut request_line).unwrap();
31980                assert!(request_line.starts_with("POST "));
31981
31982                let mut content_length = 0usize;
31983                loop {
31984                    let mut line = String::new();
31985                    reader.read_line(&mut line).unwrap();
31986                    if line == "\r\n" {
31987                        break;
31988                    }
31989                    if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
31990                        content_length = value.trim().parse().unwrap();
31991                    }
31992                }
31993
31994                let mut body = vec![0u8; content_length];
31995                reader.read_exact(&mut body).unwrap();
31996                let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
31997                server_operations
31998                    .lock()
31999                    .unwrap()
32000                    .push(request["operation"].as_str().unwrap().to_string());
32001
32002                let response = br#"{"status":"ok","message":"accepted"}"#;
32003                write!(
32004                    stream,
32005                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
32006                    response.len()
32007                )
32008                .unwrap();
32009                stream.write_all(response).unwrap();
32010            }
32011        });
32012
32013        cmd_convex_sync(
32014            ConvexSyncOptions {
32015                path: dir.path(),
32016                scope: None,
32017                snapshot: None,
32018                chunk_size: 100,
32019                remote_snapshot: false,
32020                apply: true,
32021                endpoint: Some(&endpoint),
32022                auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
32023            },
32024            OutputFormat {
32025                json_output: false,
32026                compact: true,
32027                pretty: false,
32028                terse: false,
32029                ultra_terse: false,
32030                schema: false,
32031                envelope: false,
32032            },
32033        )
32034        .unwrap();
32035        server.join().unwrap();
32036
32037        let operations = operations.lock().unwrap().clone();
32038        assert!(operations.contains(&"upsert_nodes".to_string()));
32039        assert!(operations.contains(&"upsert_edges".to_string()));
32040    }
32041
32042    #[test]
32043    fn context_pack_diff_preview_attaches_tag_ontology_refs() {
32044        let root = tempfile::tempdir().unwrap();
32045        fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
32046        fs::write(
32047            root.path().join(".naming/tags/alpha.md"),
32048            "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
32049        )
32050        .unwrap();
32051        let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
32052        let report = diff_digest::DiffDigestReport {
32053            root: root.path().display().to_string(),
32054            mode: diff_digest::DiffDigestMode::WorkingTree,
32055            revision: None,
32056            files_changed: 1,
32057            files_with_current_summaries: 1,
32058            symbols_touched: 1,
32059            call_edges_added: 0,
32060            call_edges_removed: 0,
32061            files: vec![diff_digest::DiffDigestFile {
32062                path: "src/lib.rs".to_string(),
32063                status: diff_digest::DiffDigestFileStatus::Modified,
32064                touched_symbols: vec!["alpha_helper".to_string()],
32065                summary_state: diff_digest::DiffDigestSummaryState::Current,
32066                current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
32067                    symbol: "alpha_helper".to_string(),
32068                    summary: "alpha helper summary".to_string(),
32069                }],
32070                added_call_edges: vec![],
32071                removed_call_edges: vec![],
32072                warnings: vec![],
32073            }],
32074        };
32075
32076        let preview = build_context_pack_diff_preview(
32077            &report,
32078            ResponseBudget::new(Some(1), Some(80)),
32079            Some(&ontology),
32080        );
32081
32082        let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
32083        assert!(symbol_ref.handle.starts_with("tont-"));
32084        assert_eq!(symbol_ref.tag, "alpha");
32085        assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
32086        assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
32087        assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
32088        assert_eq!(
32089            preview.files[0].summary_refs[0].ontology_refs[0].path,
32090            ".naming/tags/alpha.md"
32091        );
32092    }
32093
32094    #[test]
32095    fn context_pack_test_preview_limits_failure_groups() {
32096        let report = test_digest::TestDigestReport {
32097            root: "/repo".to_string(),
32098            runner: "cargo".to_string(),
32099            failures: 2,
32100            grouped_failures: 2,
32101            counts: test_digest::TestDigestCounts {
32102                passed: Some(8),
32103                failed: Some(2),
32104                skipped: Some(1),
32105            },
32106            failure_groups: vec![
32107                test_digest::TestDigestFailure {
32108                    tests: vec!["suite::alpha_failure".to_string()],
32109                    message: "assertion failed".to_string(),
32110                    path: Some("src/lib.rs".to_string()),
32111                    line: Some(42),
32112                    column: None,
32113                    occurrences: 1,
32114                    summary_state: test_digest::TestDigestSummaryState::Current,
32115                    current_summaries: vec![test_digest::TestDigestSummarySnippet {
32116                        symbol: "alpha_failure".to_string(),
32117                        summary: "failure summary for alpha test".to_string(),
32118                    }],
32119                },
32120                test_digest::TestDigestFailure {
32121                    tests: vec!["suite::beta_failure".to_string()],
32122                    message: "panic".to_string(),
32123                    path: Some("src/main.rs".to_string()),
32124                    line: Some(7),
32125                    column: None,
32126                    occurrences: 1,
32127                    summary_state: test_digest::TestDigestSummaryState::Missing,
32128                    current_summaries: vec![],
32129                },
32130            ],
32131            warnings: vec!["warning text".to_string()],
32132        };
32133
32134        let preview =
32135            build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32136
32137        assert!(preview.truncated);
32138        assert_eq!(preview.failure_groups.len(), 1);
32139        assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
32140        assert_eq!(preview.failure_groups[0].message, "assertion f...");
32141        assert!(
32142            preview.failure_groups[0].summary_refs[0]
32143                .handle
32144                .starts_with("ctsum-")
32145        );
32146        assert_eq!(
32147            preview.failure_groups[0].summary_refs[0].expand,
32148            "tsift summarize --file \"src/lib.rs\""
32149        );
32150        assert_eq!(preview.warnings, vec!["warning text"]);
32151    }
32152
32153    #[test]
32154    fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
32155        let dir = tempfile::tempdir().unwrap();
32156        let root = dir.path();
32157
32158        // Small log: no artifact attached, nothing written.
32159        let small_input = "Compiling serde v1.0.130\n";
32160        let mut small = log_digest::compute(root, small_input).unwrap();
32161        maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
32162        assert!(small.raw_log_artifact.is_none());
32163        assert!(!root.join(".tsift/artifacts").exists());
32164
32165        // Bulky log: artifact persisted with a stable handle and expand command.
32166        let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
32167        let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
32168        maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
32169        let artifact = bulky
32170            .raw_log_artifact
32171            .expect("artifact attached for bulky log");
32172        assert!(artifact.handle.starts_with("logdg-"));
32173        assert_eq!(artifact.bytes, bulky_input.len());
32174        assert!(artifact.expand.contains("tsift log-digest"));
32175        assert!(artifact.expand.contains("--input"));
32176        let persisted = root.join(&artifact.path);
32177        assert!(persisted.exists(), "artifact file written to {persisted:?}");
32178        assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
32179    }
32180
32181    #[test]
32182    fn context_pack_log_preview_limits_signals_and_refs() {
32183        let report = log_digest::LogDigestReport {
32184            root: "/repo".to_string(),
32185            total_lines: 12,
32186            non_empty_lines: 10,
32187            signal_groups: 2,
32188            error_signal_groups: 1,
32189            repeated_line_groups: 2,
32190            repeated_line_occurrences: 3,
32191            line_family_groups: 0,
32192            file_ref_groups: 2,
32193            symbol_ref_groups: 2,
32194            stack_groups: 1,
32195            signals: vec![
32196                log_digest::LogDigestSignal {
32197                    severity: "error".to_string(),
32198                    message: "src/lib.rs:42 boom".to_string(),
32199                    path: Some("src/lib.rs".to_string()),
32200                    line: Some(42),
32201                    column: None,
32202                    occurrences: 2,
32203                    summary_state: log_digest::LogDigestSummaryState::Current,
32204                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32205                        symbol: "alpha_helper".to_string(),
32206                        summary: "alpha helper cached log summary".to_string(),
32207                    }],
32208                },
32209                log_digest::LogDigestSignal {
32210                    severity: "warn".to_string(),
32211                    message: "slow path".to_string(),
32212                    path: None,
32213                    line: None,
32214                    column: None,
32215                    occurrences: 1,
32216                    summary_state: log_digest::LogDigestSummaryState::Unavailable,
32217                    current_summaries: vec![],
32218                },
32219            ],
32220            repeated_lines: vec![
32221                log_digest::LogDigestRepeatedLine {
32222                    line: "retrying work item alpha".to_string(),
32223                    occurrences: 3,
32224                },
32225                log_digest::LogDigestRepeatedLine {
32226                    line: "retrying work item beta".to_string(),
32227                    occurrences: 2,
32228                },
32229            ],
32230            line_families: vec![],
32231            file_refs: vec![
32232                log_digest::LogDigestFileRef {
32233                    path: "src/lib.rs".to_string(),
32234                    line: Some(42),
32235                    column: None,
32236                    occurrences: 2,
32237                    summary_state: log_digest::LogDigestSummaryState::Current,
32238                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32239                        symbol: "alpha_helper".to_string(),
32240                        summary: "alpha helper cached file summary".to_string(),
32241                    }],
32242                },
32243                log_digest::LogDigestFileRef {
32244                    path: "src/main.rs".to_string(),
32245                    line: Some(7),
32246                    column: None,
32247                    occurrences: 1,
32248                    summary_state: log_digest::LogDigestSummaryState::Missing,
32249                    current_summaries: vec![],
32250                },
32251            ],
32252            symbol_refs: vec![
32253                log_digest::LogDigestSymbolRef {
32254                    symbol: "alpha_helper".to_string(),
32255                    occurrences: 2,
32256                    summary_state: log_digest::LogDigestSummaryState::Current,
32257                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32258                        symbol: "alpha_helper".to_string(),
32259                        summary: "alpha helper cached symbol summary".to_string(),
32260                    }],
32261                },
32262                log_digest::LogDigestSymbolRef {
32263                    symbol: "beta_helper".to_string(),
32264                    occurrences: 1,
32265                    summary_state: log_digest::LogDigestSummaryState::Missing,
32266                    current_summaries: vec![],
32267                },
32268            ],
32269            stack_traces: vec![log_digest::LogDigestStackGroup {
32270                frames: vec!["frame one".to_string()],
32271                occurrences: 1,
32272            }],
32273            raw_log_artifact: None,
32274            warnings: vec!["warning text".to_string()],
32275        };
32276
32277        let preview =
32278            build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32279
32280        assert!(preview.truncated);
32281        assert_eq!(preview.signals.len(), 1);
32282        assert_eq!(preview.signals[0].message, "src/lib.rs:...");
32283        assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
32284        assert_eq!(preview.file_refs.len(), 1);
32285        assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
32286        assert!(
32287            preview.signals[0].summary_refs[0]
32288                .handle
32289                .starts_with("clsum-")
32290        );
32291        assert!(
32292            preview.file_refs[0].summary_refs[0]
32293                .handle
32294                .starts_with("clfsum-")
32295        );
32296        assert!(
32297            preview.symbol_refs[0].summary_refs[0]
32298                .handle
32299                .starts_with("clssum-")
32300        );
32301        assert_eq!(
32302            preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
32303            Some("alpha/helper")
32304        );
32305        assert_eq!(
32306            preview.symbol_refs[0].summary_refs[0].expand,
32307            "tsift summarize \"alpha_helper\""
32308        );
32309        assert_eq!(preview.warnings, vec!["warning text"]);
32310    }
32311
32312    #[test]
32313    fn cli_search_rejects_exact_with_strategy_flag() {
32314        let cli = try_parse_cli([
32315            "tsift",
32316            "search",
32317            "test",
32318            "--exact",
32319            "--strategy",
32320            "lexical",
32321        ]);
32322        assert!(cli.is_err());
32323    }
32324
32325    #[test]
32326    fn cli_search_autoindexes_by_default() {
32327        let cli = parse_cli(["tsift", "search", "test"]);
32328        match cli.command {
32329            Some(Commands::Search {
32330                autoindex,
32331                no_autoindex,
32332                ..
32333            }) => {
32334                assert!(!autoindex);
32335                assert!(!no_autoindex);
32336                assert!(autoindex || !no_autoindex);
32337            }
32338            _ => panic!("expected Search command"),
32339        }
32340    }
32341
32342    #[test]
32343    fn cli_local_model_status_accepts_json_and_no_probe() {
32344        let cli = parse_cli(["tsift", "local-model", "status", "--json", "--no-probe"]);
32345        match cli.command {
32346            Some(Commands::LocalModel {
32347                command: LocalModelCommand::Status { json, no_probe },
32348            }) => {
32349                assert!(json);
32350                assert!(no_probe);
32351            }
32352            _ => panic!("expected LocalModel status command"),
32353        }
32354    }
32355
32356    #[test]
32357    fn cli_local_model_unload_accepts_probe_and_strict_flags() {
32358        let cli = parse_cli([
32359            "tsift",
32360            "local-model",
32361            "unload",
32362            "--profile",
32363            "qwen3-32b-q4",
32364            "--pre-used-mib",
32365            "200",
32366            "--post-used-mib",
32367            "800",
32368            "--provider-pid",
32369            "42",
32370            "--strict",
32371            "--json",
32372        ]);
32373        match cli.command {
32374            Some(Commands::LocalModel {
32375                command:
32376                    LocalModelCommand::Unload {
32377                        profile,
32378                        provider_pid,
32379                        pre_used_mib,
32380                        post_used_mib,
32381                        strict,
32382                        json,
32383                        ..
32384                    },
32385            }) => {
32386                assert_eq!(profile, "qwen3-32b-q4");
32387                assert_eq!(provider_pid, Some(42));
32388                assert_eq!(pre_used_mib, Some(200));
32389                assert_eq!(post_used_mib, Some(800));
32390                assert!(strict);
32391                assert!(json);
32392            }
32393            _ => panic!("expected LocalModel unload command"),
32394        }
32395    }
32396
32397    #[test]
32398    fn cli_local_model_swap_parses_flags() {
32399        let cli = parse_cli([
32400            "tsift",
32401            "local-model",
32402            "swap",
32403            "--from",
32404            "qwen3-32b-q4",
32405            "--to",
32406            "qwen3-embedding-0.6b",
32407            "--provider-pid",
32408            "42",
32409            "--pre-used-mib",
32410            "200",
32411            "--post-used-mib",
32412            "180",
32413            "--strict",
32414            "--json",
32415        ]);
32416        match cli.command {
32417            Some(Commands::LocalModel {
32418                command:
32419                    LocalModelCommand::Swap {
32420                        from,
32421                        to,
32422                        provider_pid,
32423                        pre_used_mib,
32424                        post_used_mib,
32425                        strict,
32426                        json,
32427                        ..
32428                    },
32429            }) => {
32430                assert_eq!(from, "qwen3-32b-q4");
32431                assert_eq!(to, "qwen3-embedding-0.6b");
32432                assert_eq!(provider_pid, Some(42));
32433                assert_eq!(pre_used_mib, Some(200));
32434                assert_eq!(post_used_mib, Some(180));
32435                assert!(strict);
32436                assert!(json);
32437            }
32438            _ => panic!("expected LocalModel swap command"),
32439        }
32440    }
32441
32442    #[test]
32443    fn cli_local_model_resolve_parses_flags() {
32444        use cli::ResolveRole;
32445        let cli = parse_cli([
32446            "tsift",
32447            "local-model",
32448            "resolve",
32449            "--profile",
32450            "hash",
32451            "--role",
32452            "embed",
32453            "--no-probe",
32454            "--json",
32455        ]);
32456        match cli.command {
32457            Some(Commands::LocalModel {
32458                command:
32459                    LocalModelCommand::Resolve {
32460                        profile,
32461                        role,
32462                        no_probe,
32463                        json,
32464                    },
32465            }) => {
32466                assert_eq!(profile.as_deref(), Some("hash"));
32467                assert_eq!(role, ResolveRole::Embed);
32468                assert!(no_probe);
32469                assert!(json);
32470            }
32471            _ => panic!("expected LocalModel resolve command"),
32472        }
32473    }
32474
32475    #[test]
32476    fn cli_semantic_command_accepts_profile_flag() {
32477        let cli = parse_cli([
32478            "tsift",
32479            "semantic",
32480            "auth",
32481            "--profile",
32482            "qwen3-embedding-0.6b",
32483            "--json",
32484        ]);
32485        match cli.command {
32486            Some(Commands::Semantic { profile, query, .. }) => {
32487                assert_eq!(query, "auth");
32488                assert_eq!(profile.as_deref(), Some("qwen3-embedding-0.6b"));
32489            }
32490            _ => panic!("expected Semantic command"),
32491        }
32492    }
32493
32494    #[test]
32495    fn cli_summarize_command_accepts_profile_flag() {
32496        let cli = parse_cli([
32497            "tsift",
32498            "summarize",
32499            "--extract",
32500            "src",
32501            "--profile",
32502            "hash",
32503            "--json",
32504        ]);
32505        match cli.command {
32506            Some(Commands::Summarize {
32507                extract,
32508                profile,
32509                json,
32510                ..
32511            }) => {
32512                assert_eq!(extract.as_deref(), Some(std::path::Path::new("src")));
32513                assert_eq!(profile.as_deref(), Some("hash"));
32514                assert!(json);
32515            }
32516            _ => panic!("expected Summarize command"),
32517        }
32518    }
32519
32520    #[test]
32521    fn cli_local_model_lease_acquire_parses_flags() {
32522        let cli = parse_cli([
32523            "tsift",
32524            "local-model",
32525            "lease",
32526            "acquire",
32527            "--profile",
32528            "qwen3-32b-q4",
32529            "--holder-pid",
32530            "4242",
32531            "--holder-command",
32532            "corky",
32533            "--idle-ttl-seconds",
32534            "120",
32535            "--vram-baseline-mib",
32536            "200",
32537            "--lease-file",
32538            "/tmp/tsift-lease.json",
32539            "--strict",
32540            "--json",
32541        ]);
32542        match cli.command {
32543            Some(Commands::LocalModel {
32544                command:
32545                    LocalModelCommand::Lease {
32546                        command:
32547                            LeaseCommand::Acquire {
32548                                profile,
32549                                holder_pid,
32550                                holder_command,
32551                                idle_ttl_seconds,
32552                                vram_baseline_mib,
32553                                lease_file,
32554                                strict,
32555                                json,
32556                                ..
32557                            },
32558                    },
32559            }) => {
32560                assert_eq!(profile, "qwen3-32b-q4");
32561                assert_eq!(holder_pid, Some(4242));
32562                assert_eq!(holder_command, "corky");
32563                assert_eq!(idle_ttl_seconds, 120);
32564                assert_eq!(vram_baseline_mib, Some(200));
32565                assert_eq!(lease_file, Some(PathBuf::from("/tmp/tsift-lease.json")));
32566                assert!(strict);
32567                assert!(json);
32568            }
32569            _ => panic!("expected LocalModel lease acquire command"),
32570        }
32571    }
32572
32573    #[test]
32574    fn cli_local_model_lease_release_parses_flags() {
32575        let cli = parse_cli([
32576            "tsift",
32577            "local-model",
32578            "lease",
32579            "release",
32580            "--profile",
32581            "qwen3-embedding-0.6b",
32582            "--holder-pid",
32583            "999",
32584            "--json",
32585        ]);
32586        match cli.command {
32587            Some(Commands::LocalModel {
32588                command:
32589                    LocalModelCommand::Lease {
32590                        command:
32591                            LeaseCommand::Release {
32592                                profile,
32593                                holder_pid,
32594                                json,
32595                                ..
32596                            },
32597                    },
32598            }) => {
32599                assert_eq!(profile, "qwen3-embedding-0.6b");
32600                assert_eq!(holder_pid, Some(999));
32601                assert!(json);
32602            }
32603            _ => panic!("expected LocalModel lease release command"),
32604        }
32605    }
32606
32607    #[test]
32608    fn cli_local_model_lease_show_parses_flags() {
32609        let cli = parse_cli([
32610            "tsift",
32611            "local-model",
32612            "lease",
32613            "show",
32614            "--include-stale",
32615            "--json",
32616        ]);
32617        match cli.command {
32618            Some(Commands::LocalModel {
32619                command:
32620                    LocalModelCommand::Lease {
32621                        command:
32622                            LeaseCommand::Show {
32623                                include_stale,
32624                                json,
32625                                ..
32626                            },
32627                    },
32628            }) => {
32629                assert!(include_stale);
32630                assert!(json);
32631            }
32632            _ => panic!("expected LocalModel lease show command"),
32633        }
32634    }
32635
32636    #[test]
32637    fn cli_search_accepts_no_autoindex_flag() {
32638        let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
32639        match cli.command {
32640            Some(Commands::Search {
32641                autoindex,
32642                no_autoindex,
32643                ..
32644            }) => {
32645                assert!(!autoindex);
32646                assert!(no_autoindex);
32647            }
32648            _ => panic!("expected Search command"),
32649        }
32650    }
32651
32652    #[test]
32653    fn cli_search_rejects_conflicting_autoindex_flags() {
32654        let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
32655        assert!(cli.is_err());
32656    }
32657
32658    // --- relativize paths ---
32659
32660    #[test]
32661    fn cli_accepts_global_absolute_flag() {
32662        let cli = parse_cli(["tsift", "--absolute", "status"]);
32663        assert!(cli.absolute);
32664        assert!(matches!(cli.command, Some(Commands::Status { .. })));
32665    }
32666
32667    #[test]
32668    fn cli_accepts_global_tabular_flag() {
32669        let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
32670        assert!(cli.tabular);
32671        assert!(matches!(cli.command, Some(Commands::Search { .. })));
32672    }
32673
32674    #[test]
32675    fn cli_tabular_with_graph() {
32676        let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
32677        assert!(cli.tabular);
32678        assert!(matches!(cli.command, Some(Commands::Graph { .. })));
32679    }
32680
32681    #[test]
32682    fn cli_tabular_with_communities() {
32683        let cli = parse_cli(["tsift", "--tabular", "communities"]);
32684        assert!(cli.tabular);
32685        assert!(matches!(cli.command, Some(Commands::Communities { .. })));
32686    }
32687
32688    #[test]
32689    fn cli_tabular_with_explain() {
32690        let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
32691        assert!(cli.tabular);
32692        assert!(matches!(cli.command, Some(Commands::Explain { .. })));
32693    }
32694
32695    #[test]
32696    fn cli_traverse_accepts_path_target_and_html_format() {
32697        let cli = parse_cli([
32698            "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
32699        ]);
32700        match cli.command {
32701            Some(Commands::Traverse {
32702                node,
32703                to,
32704                path,
32705                format,
32706                ..
32707            }) => {
32708                assert_eq!(node.as_deref(), Some("#kgnv"));
32709                assert_eq!(to.as_deref(), Some("main"));
32710                assert_eq!(path, PathBuf::from("."));
32711                assert_eq!(format, TraverseFormat::Html);
32712            }
32713            _ => panic!("expected Traverse command"),
32714        }
32715    }
32716
32717    #[test]
32718    fn cli_parses_semantic_related_command() {
32719        let cli = parse_cli([
32720            "tsift",
32721            "semantic",
32722            "graph navigation",
32723            "--path",
32724            ".",
32725            "--kind",
32726            "all",
32727            "--limit",
32728            "3",
32729            "--json",
32730        ]);
32731        match cli.command {
32732            Some(Commands::Semantic {
32733                query,
32734                path,
32735                kind,
32736                limit,
32737                json,
32738                ..
32739            }) => {
32740                assert_eq!(query, "graph navigation");
32741                assert_eq!(path, PathBuf::from("."));
32742                assert_eq!(kind, SemanticRelatedKind::All);
32743                assert_eq!(limit, 3);
32744                assert!(json);
32745            }
32746            _ => panic!("expected Semantic command"),
32747        }
32748    }
32749
32750    #[test]
32751    fn cli_parses_convex_sync_command() {
32752        let cli = parse_cli([
32753            "tsift",
32754            "convex-sync",
32755            ".",
32756            "--snapshot",
32757            "rows.json",
32758            "--chunk-size",
32759            "25",
32760            "--json",
32761        ]);
32762        match cli.command {
32763            Some(Commands::ConvexSync {
32764                path,
32765                snapshot,
32766                chunk_size,
32767                json,
32768                ..
32769            }) => {
32770                assert_eq!(path, PathBuf::from("."));
32771                assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
32772                assert_eq!(chunk_size, 25);
32773                assert!(json);
32774            }
32775            _ => panic!("expected ConvexSync command"),
32776        }
32777    }
32778
32779    #[test]
32780    fn cli_parses_convex_sync_live_flags() {
32781        let cli = parse_cli([
32782            "tsift",
32783            "convex-sync",
32784            ".",
32785            "--remote-snapshot",
32786            "--apply",
32787            "--endpoint",
32788            "https://example.test/convex-graph",
32789            "--auth-token-env",
32790            "TSIFT_TEST_TOKEN",
32791        ]);
32792        match cli.command {
32793            Some(Commands::ConvexSync {
32794                remote_snapshot,
32795                apply,
32796                endpoint,
32797                auth_token_env,
32798                ..
32799            }) => {
32800                assert!(remote_snapshot);
32801                assert!(apply);
32802                assert_eq!(
32803                    endpoint.as_deref(),
32804                    Some("https://example.test/convex-graph")
32805                );
32806                assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
32807            }
32808            _ => panic!("expected ConvexSync command"),
32809        }
32810    }
32811
32812    #[test]
32813    fn cli_parses_graph_db_query() {
32814        let cli = parse_cli([
32815            "tsift",
32816            "graph-db",
32817            "--backend",
32818            "convex-snapshot",
32819            "--convex-snapshot",
32820            "rows.json",
32821            "--json",
32822            "neighborhood",
32823            "gbak-kgnv",
32824            "--depth",
32825            "2",
32826            "--edge-kind",
32827            "mentions",
32828            "--property",
32829            "path=tasks/software/tsift.md",
32830            "--cursor",
32831            "gbak-old",
32832            "--limit",
32833            "10",
32834        ]);
32835        match cli.command {
32836            Some(Commands::GraphDb {
32837                backend,
32838                convex_snapshot,
32839                json,
32840                query,
32841                ..
32842            }) => {
32843                assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
32844                assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
32845                assert!(json);
32846                match query {
32847                    GraphDbQuery::Neighborhood {
32848                        id,
32849                        depth,
32850                        edge_kind,
32851                        cursor,
32852                        limit,
32853                        property_filters,
32854                    } => {
32855                        assert_eq!(id, "gbak-kgnv");
32856                        assert_eq!(depth, 2);
32857                        assert_eq!(edge_kind.as_deref(), Some("mentions"));
32858                        assert_eq!(cursor.as_deref(), Some("gbak-old"));
32859                        assert_eq!(limit, Some(10));
32860                        assert_eq!(
32861                            property_filters,
32862                            vec!["path=tasks/software/tsift.md".to_string()]
32863                        );
32864                    }
32865                    _ => panic!("expected graph-db neighborhood query"),
32866                }
32867            }
32868            _ => panic!("expected GraphDb command"),
32869        }
32870    }
32871
32872    #[test]
32873    fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
32874        let cli = parse_cli([
32875            "tsift",
32876            "graph-db",
32877            "--json",
32878            "backend-eval",
32879            "--candidate",
32880            "surrealdb",
32881            "--target",
32882            "gval",
32883            "--full-projection",
32884        ]);
32885        match cli.command {
32886            Some(Commands::GraphDb { json, query, .. }) => {
32887                assert!(json);
32888                match query {
32889                    GraphDbQuery::BackendEval {
32890                        candidates,
32891                        targets,
32892                        full_projection,
32893                    } => {
32894                        assert_eq!(candidates, vec!["surrealdb".to_string()]);
32895                        assert_eq!(targets, vec!["gval".to_string()]);
32896                        assert!(full_projection);
32897                    }
32898                    _ => panic!("expected graph-db backend-eval query"),
32899                }
32900            }
32901            _ => panic!("expected GraphDb command"),
32902        }
32903    }
32904
32905    #[test]
32906    fn cli_parses_graph_db_tokensave_backend() {
32907        let cli = parse_cli([
32908            "tsift",
32909            "graph-db",
32910            "--backend",
32911            "tokensave",
32912            "--json",
32913            "node",
32914            "fn:main",
32915        ]);
32916        match cli.command {
32917            Some(Commands::GraphDb {
32918                backend,
32919                json,
32920                query,
32921                ..
32922            }) => {
32923                assert_eq!(backend, GraphDbBackend::Tokensave);
32924                assert!(json);
32925                match query {
32926                    GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
32927                    _ => panic!("expected graph-db node query"),
32928                }
32929            }
32930            _ => panic!("expected GraphDb command"),
32931        }
32932    }
32933
32934    #[test]
32935    fn cli_parses_analyze_command() {
32936        let cli = parse_cli([
32937            "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
32938            "--limit", "7", "--json",
32939        ]);
32940        match cli.command {
32941            Some(Commands::Analyze {
32942                path,
32943                scope,
32944                entry_points,
32945                limit,
32946                json,
32947            }) => {
32948                assert_eq!(path, PathBuf::from("."));
32949                assert_eq!(scope.as_deref(), Some("core"));
32950                assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
32951                assert_eq!(limit, 7);
32952                assert!(json);
32953            }
32954            _ => panic!("expected Analyze command"),
32955        }
32956    }
32957
32958    #[test]
32959    fn cli_parses_graph_db_related_query() {
32960        let cli = parse_cli([
32961            "tsift",
32962            "graph-db",
32963            "--json",
32964            "related",
32965            "voice avatar memory retrieval",
32966            "--kind",
32967            "all",
32968            "--depth",
32969            "3",
32970            "--seed-limit",
32971            "4",
32972            "--limit",
32973            "12",
32974        ]);
32975        match cli.command {
32976            Some(Commands::GraphDb { json, query, .. }) => {
32977                assert!(json);
32978                match query {
32979                    GraphDbQuery::Related {
32980                        query,
32981                        kind,
32982                        depth,
32983                        seed_limit,
32984                        limit,
32985                    } => {
32986                        assert_eq!(query, "voice avatar memory retrieval");
32987                        assert_eq!(kind, SemanticRelatedKind::All);
32988                        assert_eq!(depth, 3);
32989                        assert_eq!(seed_limit, 4);
32990                        assert_eq!(limit, 12);
32991                    }
32992                    _ => panic!("expected graph-db related query"),
32993                }
32994            }
32995            _ => panic!("expected GraphDb command"),
32996        }
32997    }
32998
32999    #[test]
33000    fn cli_parses_graph_db_compact_query() {
33001        let cli = parse_cli([
33002            "tsift",
33003            "graph-db",
33004            "--path",
33005            ".",
33006            "compact",
33007            "--apply",
33008            "--prune-tombstones",
33009            "--confirmed-convex-reconciled",
33010        ]);
33011        match cli.command {
33012            Some(Commands::GraphDb { query, .. }) => match query {
33013                GraphDbQuery::Compact {
33014                    apply,
33015                    prune_tombstones,
33016                    confirmed_convex_reconciled,
33017                } => {
33018                    assert!(apply);
33019                    assert!(prune_tombstones);
33020                    assert!(confirmed_convex_reconciled);
33021                }
33022                _ => panic!("expected graph-db compact query"),
33023            },
33024            _ => panic!("expected GraphDb command"),
33025        }
33026    }
33027
33028    #[test]
33029    fn cli_parses_graph_db_snapshot_queries() {
33030        let export_cli = parse_cli([
33031            "tsift",
33032            "graph-db",
33033            "--json",
33034            "snapshot-export",
33035            "graph.db.gz",
33036            "--force",
33037        ]);
33038        match export_cli.command {
33039            Some(Commands::GraphDb { json, query, .. }) => {
33040                assert!(json);
33041                match query {
33042                    GraphDbQuery::SnapshotExport { output, force } => {
33043                        assert_eq!(output, PathBuf::from("graph.db.gz"));
33044                        assert!(force);
33045                    }
33046                    _ => panic!("expected graph-db snapshot-export query"),
33047                }
33048            }
33049            _ => panic!("expected GraphDb command"),
33050        }
33051
33052        let import_cli = parse_cli([
33053            "tsift",
33054            "graph-db",
33055            "snapshot-import",
33056            "graph.db.gz",
33057            "--replace",
33058        ]);
33059        match import_cli.command {
33060            Some(Commands::GraphDb { query, .. }) => match query {
33061                GraphDbQuery::SnapshotImport { artifact, replace } => {
33062                    assert_eq!(artifact, PathBuf::from("graph.db.gz"));
33063                    assert!(replace);
33064                }
33065                _ => panic!("expected graph-db snapshot-import query"),
33066            },
33067            _ => panic!("expected GraphDb command"),
33068        }
33069    }
33070
33071    #[test]
33072    fn cli_parses_impact_command() {
33073        let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
33074        match cli.command {
33075            Some(Commands::Impact {
33076                path,
33077                cached,
33078                limit,
33079                ..
33080            }) => {
33081                assert_eq!(path, PathBuf::from("."));
33082                assert!(cached);
33083                assert_eq!(limit, 5);
33084            }
33085            _ => panic!("expected Impact command"),
33086        }
33087    }
33088
33089    #[test]
33090    fn cli_parses_conflict_matrix_command() {
33091        let cli = parse_cli([
33092            "tsift",
33093            "conflict-matrix",
33094            "--path",
33095            "tasks/software/tsift.md",
33096            "--depth",
33097            "4",
33098            "--limit",
33099            "12",
33100            "--impact-limit",
33101            "6",
33102            "--json",
33103            "pwcm",
33104            "#g6kf",
33105        ]);
33106        match cli.command {
33107            Some(Commands::ConflictMatrix {
33108                targets,
33109                path,
33110                depth,
33111                limit,
33112                impact_limit,
33113                json,
33114                ..
33115            }) => {
33116                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33117                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33118                assert_eq!(depth, 4);
33119                assert_eq!(limit, 12);
33120                assert_eq!(impact_limit, 6);
33121                assert!(json);
33122            }
33123            _ => panic!("expected ConflictMatrix command"),
33124        }
33125    }
33126
33127    #[test]
33128    fn cli_parses_dispatch_trace_command() {
33129        let cli = parse_cli([
33130            "tsift",
33131            "dispatch-trace",
33132            "--path",
33133            "tasks/software/tsift.md",
33134            "--format",
33135            "html",
33136            "--depth",
33137            "4",
33138            "pwcm",
33139            "#g6kf",
33140        ]);
33141        match cli.command {
33142            Some(Commands::DispatchTrace {
33143                targets,
33144                path,
33145                format,
33146                depth,
33147                ..
33148            }) => {
33149                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33150                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33151                assert_eq!(format, DispatchTraceFormat::Html);
33152                assert_eq!(depth, 4);
33153            }
33154            _ => panic!("expected DispatchTrace command"),
33155        }
33156    }
33157
33158    #[test]
33159    fn cli_parses_dependency_dag_command() {
33160        let cli = parse_cli([
33161            "tsift",
33162            "dependency-dag",
33163            "--path",
33164            "tasks/software/tsift.md",
33165            "--depth",
33166            "5",
33167            "--limit",
33168            "20",
33169            "--json",
33170            "alpha",
33171            "#beta",
33172        ]);
33173        match cli.command {
33174            Some(Commands::DependencyDag {
33175                targets,
33176                path,
33177                depth,
33178                limit,
33179                json,
33180                ..
33181            }) => {
33182                assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
33183                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33184                assert_eq!(depth, 5);
33185                assert_eq!(limit, 20);
33186                assert!(json);
33187            }
33188            _ => panic!("expected DependencyDag command"),
33189        }
33190    }
33191
33192    #[test]
33193    fn relativize_strips_root_prefix() {
33194        let root = std::path::Path::new("/home/user/project");
33195        assert_eq!(
33196            relativize("/home/user/project/src/main.rs", root),
33197            "src/main.rs"
33198        );
33199    }
33200
33201    #[test]
33202    fn relativize_leaves_non_matching_path() {
33203        let root = std::path::Path::new("/home/user/project");
33204        assert_eq!(
33205            relativize("/other/path/file.rs", root),
33206            "/other/path/file.rs"
33207        );
33208    }
33209
33210    #[test]
33211    fn relativize_leaves_already_relative() {
33212        let root = std::path::Path::new("/home/user/project");
33213        assert_eq!(relativize("src/main.rs", root), "src/main.rs");
33214    }
33215
33216    #[test]
33217    fn relativize_pathbuf_strips_prefix() {
33218        let root = std::path::Path::new("/home/user/project");
33219        let path = std::path::Path::new("/home/user/project/src/lib.rs");
33220        assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
33221    }
33222
33223    #[test]
33224    fn relativize_edges_strips_caller_file() {
33225        let root = std::path::Path::new("/tmp/proj");
33226        let mut edges = vec![index::StoredEdge {
33227            caller_file: "/tmp/proj/src/main.rs".to_string(),
33228            caller_name: "main".to_string(),
33229            caller_line: 1,
33230            callee_name: "helper".to_string(),
33231            call_site_line: 5,
33232            tagpath_handle: None,
33233        }];
33234        relativize_edges(&mut edges, root);
33235        assert_eq!(edges[0].caller_file, "src/main.rs");
33236    }
33237
33238    #[test]
33239    fn relativize_json_paths_strips_known_keys() {
33240        let root = std::path::Path::new("/tmp/proj");
33241        let mut val = serde_json::json!({
33242            "file": "/tmp/proj/src/main.rs",
33243            "path": "/tmp/proj/test.rs",
33244            "name": "/tmp/proj/not-a-path",
33245            "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
33246        });
33247        relativize_json_paths(&mut val, root);
33248        assert_eq!(val["file"], "src/main.rs");
33249        assert_eq!(val["path"], "test.rs");
33250        assert_eq!(val["name"], "/tmp/proj/not-a-path");
33251        assert_eq!(val["hits"][0]["path"], "nested.rs");
33252    }
33253
33254    // --- limit caps ---
33255
33256    #[test]
33257    fn cli_graph_accepts_limit_flag() {
33258        let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
33259        match cli.command {
33260            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
33261            _ => panic!("expected Graph command"),
33262        }
33263    }
33264
33265    #[test]
33266    fn cli_graph_default_limit_is_20() {
33267        let cli = parse_cli(["tsift", "graph", "main"]);
33268        match cli.command {
33269            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
33270            _ => panic!("expected Graph command"),
33271        }
33272    }
33273
33274    #[test]
33275    fn cli_communities_accepts_limit_flag() {
33276        let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
33277        match cli.command {
33278            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
33279            _ => panic!("expected Communities command"),
33280        }
33281    }
33282
33283    #[test]
33284    fn cli_communities_default_limit_is_10() {
33285        let cli = parse_cli(["tsift", "communities"]);
33286        match cli.command {
33287            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
33288            _ => panic!("expected Communities command"),
33289        }
33290    }
33291
33292    #[test]
33293    fn cli_explain_accepts_limit_flag() {
33294        let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
33295        match cli.command {
33296            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
33297            _ => panic!("expected Explain command"),
33298        }
33299    }
33300
33301    #[test]
33302    fn cli_explain_default_limit_is_15() {
33303        let cli = parse_cli(["tsift", "explain", "main"]);
33304        match cli.command {
33305            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
33306            _ => panic!("expected Explain command"),
33307        }
33308    }
33309
33310    #[test]
33311    fn cli_limit_zero_means_unlimited() {
33312        let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
33313        match cli.command {
33314            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
33315            _ => panic!("expected Graph command"),
33316        }
33317    }
33318
33319    #[test]
33320    fn graph_cmd_limit_runs_ok() {
33321        let dir = setup_graph_index();
33322        let result = cmd_graph(
33323            "main",
33324            dir.path(),
33325            false,
33326            false,
33327            None,
33328            1,
33329            false,
33330            false,
33331            false,
33332            false,
33333            false,
33334            false,
33335            false,
33336            TagpathSearchOpts::default(),
33337        );
33338        assert!(result.is_ok());
33339    }
33340
33341    #[test]
33342    fn graph_cmd_unlimited_runs_ok() {
33343        let dir = setup_graph_index();
33344        let result = cmd_graph(
33345            "main",
33346            dir.path(),
33347            false,
33348            false,
33349            None,
33350            0,
33351            false,
33352            false,
33353            false,
33354            false,
33355            false,
33356            false,
33357            false,
33358            TagpathSearchOpts::default(),
33359        );
33360        assert!(result.is_ok());
33361    }
33362
33363    #[test]
33364    fn graph_cmd_tabular_runs_ok() {
33365        let dir = setup_graph_index();
33366        let result = cmd_graph(
33367            "main",
33368            dir.path(),
33369            false,
33370            false,
33371            None,
33372            20,
33373            false,
33374            false,
33375            false,
33376            false,
33377            false,
33378            true,
33379            false,
33380            TagpathSearchOpts::default(),
33381        );
33382        assert!(result.is_ok());
33383    }
33384
33385    #[test]
33386    fn communities_cmd_tabular_runs_ok() {
33387        let dir = setup_graph_index();
33388        let result = cmd_communities(
33389            dir.path(),
33390            None,
33391            1,
33392            10,
33393            false,
33394            false,
33395            false,
33396            false,
33397            true,
33398            false,
33399            TagpathSearchOpts::default(),
33400        );
33401        assert!(result.is_ok());
33402    }
33403
33404    #[test]
33405    fn explain_cmd_tabular_runs_ok() {
33406        let dir = setup_graph_index();
33407        let result = cmd_explain(
33408            "main",
33409            dir.path(),
33410            None,
33411            15,
33412            false,
33413            false,
33414            false,
33415            false,
33416            false,
33417            true,
33418            false,
33419            false,
33420        );
33421        assert!(result.is_ok());
33422    }
33423
33424    #[test]
33425    fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
33426        // #gdbcacheprove: .agent-doc runtime markdown (snapshots, baselines, archives,
33427        // session docs, runtime logs) must not contribute to the source watermark, or
33428        // every agent-doc cycle would invalidate the graph-db backend-eval cache and
33429        // force a full rebuild on the next run.
33430        let cases = [
33431            ".agent-doc",
33432            ".agent-doc/snapshots/abc.md",
33433            ".agent-doc/baselines/abc.md",
33434            ".agent-doc/archives/2026.md",
33435            ".agent-doc/runtime/run.jsonl",
33436            "src/foo/.agent-doc",
33437            "src/foo/.agent-doc/snapshots/x.md",
33438            "./.agent-doc/snapshots/x.md",
33439        ];
33440        for path in cases {
33441            assert!(
33442                traversal_relative_path_is_generated_artifact(path),
33443                "expected `{path}` to be excluded from source watermark"
33444            );
33445        }
33446        // Real source paths must NOT be excluded.
33447        for path in [
33448            "src/main.rs",
33449            "tests/perf_gate.rs",
33450            "fixtures/x.json",
33451            "agent-doc/src/lib.rs", // sibling dir without the leading dot
33452            "src/.agent-doc-helper.rs",
33453        ] {
33454            assert!(
33455                !traversal_relative_path_is_generated_artifact(path),
33456                "expected `{path}` to be included in source watermark"
33457            );
33458        }
33459    }
33460
33461    #[test]
33462    fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
33463        // #cachelookupshift: the conflict-matrix preparation cache key hashes
33464        // file_state snapshot rows + every markdown file under the root. Any
33465        // .tsift/, target/, or .agent-doc/ path slipping past the filter would
33466        // shift the watermark every run because those directories mutate as a
33467        // side effect of running tsift itself. This test locks the artifact
33468        // filter against regressions for each prefix variant
33469        // (bare, root-anchored, nested, and './' leading).
33470        let cases = [
33471            ".tsift",
33472            ".tsift/index.db",
33473            ".tsift/indexes/foo/index.db",
33474            ".tsift/conflict-matrix-cache/inputs/abc.json",
33475            ".tsift/summaries.db",
33476            "src/foo/.tsift",
33477            "src/foo/.tsift/graph.db",
33478            "./.tsift/index.db",
33479            "target",
33480            "target/debug/build/x",
33481            "target/release/tsift",
33482            "src/foo/target/debug/x",
33483            "./target/release/x",
33484        ];
33485        for path in cases {
33486            assert!(
33487                traversal_relative_path_is_generated_artifact(path),
33488                "expected `{path}` to be excluded from source watermark"
33489            );
33490        }
33491        // Look-alike paths must NOT be excluded — only true artifact dirs.
33492        for path in [
33493            "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
33494            "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
33495            "src/tsift-extras/lib.rs",
33496            "tsift/README.md",
33497            "src/targeting.rs",
33498            "src/.tsiftrc",
33499            "src/agent-doc-helper.rs",
33500        ] {
33501            assert!(
33502                !traversal_relative_path_is_generated_artifact(path),
33503                "expected `{path}` to be included in source watermark"
33504            );
33505        }
33506    }
33507
33508    #[test]
33509    fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
33510        // #cachelookupshift: the conflict-matrix preparation cache only hits
33511        // when traversal_source_watermark returns the same hash for two
33512        // consecutive calls on identical source state. Lock that invariant so
33513        // a future change that folds wall-clock time, a directory mtime, or
33514        // any other non-content input into the hash trips this test before
33515        // regressing the preparation_cache_lookup hit rate. We exercise the
33516        // session_only=true path with a hinted markdown file so the test does
33517        // not need a full index DB to drive the index-snapshot branch.
33518        let dir = tempfile::tempdir().unwrap();
33519        let root = dir.path();
33520        std::fs::create_dir_all(root.join("src")).unwrap();
33521        std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
33522        let hint = root.join("README.md");
33523        std::fs::write(&hint, "# stable\n").unwrap();
33524        // Add a generated-artifact directory that must NOT affect the watermark.
33525        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33526        std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
33527        std::fs::create_dir_all(root.join("target/debug")).unwrap();
33528        std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
33529
33530        let first = traversal_source_watermark(root, &hint, None, true)
33531            .expect("first watermark call must succeed")
33532            .expect("first watermark must produce a hash for hinted markdown");
33533        let second = traversal_source_watermark(root, &hint, None, true)
33534            .expect("second watermark call must succeed")
33535            .expect("second watermark must produce a hash for hinted markdown");
33536        assert_eq!(
33537            first, second,
33538            "watermark must be identical across back-to-back invocations on a quiescent root"
33539        );
33540
33541        // Mutating a generated-artifact file must NOT shift the hash.
33542        std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
33543        std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
33544        let third = traversal_source_watermark(root, &hint, None, true)
33545            .expect("third watermark call must succeed")
33546            .expect("third watermark must produce a hash for hinted markdown");
33547        assert_eq!(
33548            first, third,
33549            "watermark must ignore mutations under .tsift/ and target/"
33550        );
33551
33552        // Mutating the hinted markdown file MUST shift the hash so the
33553        // preparation cache invalidates correctly when user state changes.
33554        // Sleep briefly to push the file mtime past the original even on
33555        // coarse-resolution filesystems.
33556        std::thread::sleep(std::time::Duration::from_millis(20));
33557        std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
33558        let fourth = traversal_source_watermark(root, &hint, None, true)
33559            .expect("fourth watermark call must succeed")
33560            .expect("fourth watermark must produce a hash for hinted markdown");
33561        assert_ne!(
33562            first, fourth,
33563            "watermark must invalidate when the hinted markdown file changes"
33564        );
33565    }
33566
33567    #[test]
33568    fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
33569        // #gcachemiss: full-projection cache keys must not miss just because the
33570        // SQLite summary cache file header or mtime churned. Only the semantic rows
33571        // that feed traversal projection should participate in the source watermark.
33572        let dir = tempfile::tempdir().unwrap();
33573        let root = dir.path();
33574        std::fs::write(root.join("README.md"), "# stable\n").unwrap();
33575        let summaries_db_path = root.join(".tsift/summaries.db");
33576        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33577        let mut summary = summarize::Summary {
33578            id: 0,
33579            symbol_name: "main".to_string(),
33580            file_path: "src/main.rs".to_string(),
33581            content_hash: "hash-main".to_string(),
33582            summary: "main wires the CLI".to_string(),
33583            entities: Some(vec![summarize::Entity {
33584                name: "Cli".to_string(),
33585                kind: "type".to_string(),
33586                description: "Command-line interface".to_string(),
33587            }]),
33588            relationships: None,
33589            concept_labels: Some(vec!["cli".to_string()]),
33590            extracted_at: "1700000000".to_string(),
33591            model: "test-model".to_string(),
33592            tokens_input: Some(10),
33593            tokens_output: Some(5),
33594        };
33595        summary_db.insert(&summary).unwrap();
33596        drop(summary_db);
33597
33598        let hint = root.join("README.md");
33599        let first = traversal_source_watermark(root, &hint, None, true)
33600            .expect("first watermark call must succeed")
33601            .expect("first watermark must produce a hash");
33602
33603        std::thread::sleep(std::time::Duration::from_millis(20));
33604        let conn = Connection::open(&summaries_db_path).unwrap();
33605        conn.pragma_update(None, "user_version", 1).unwrap();
33606        conn.pragma_update(None, "user_version", 0).unwrap();
33607        drop(conn);
33608
33609        let second = traversal_source_watermark(root, &hint, None, true)
33610            .expect("second watermark call must succeed")
33611            .expect("second watermark must produce a hash");
33612        assert_eq!(
33613            first, second,
33614            "metadata-only summaries.db churn must not invalidate the source watermark"
33615        );
33616
33617        summary.entities = Some(vec![summarize::Entity {
33618            name: "GraphCache".to_string(),
33619            kind: "type".to_string(),
33620            description: "Stable full-projection cache input".to_string(),
33621        }]);
33622        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33623        summary_db.delete_by_file("src/main.rs").unwrap();
33624        summary_db.insert(&summary).unwrap();
33625        drop(summary_db);
33626
33627        let third = traversal_source_watermark(root, &hint, None, true)
33628            .expect("third watermark call must succeed")
33629            .expect("third watermark must produce a hash");
33630        assert_ne!(
33631            first, third,
33632            "semantic summary row changes must invalidate the source watermark"
33633        );
33634    }
33635
33636    #[test]
33637    fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
33638        // #gfullhot: backend-eval full-projection cache keys should be based on
33639        // the indexed graph inputs, not file_state mtimes. Touching a source file
33640        // without changing extracted symbols/call edges must still hit the cache.
33641        let dir = tempfile::tempdir().unwrap();
33642        let root = dir.path();
33643        std::fs::create_dir_all(root.join("src")).unwrap();
33644        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33645        let source = root.join("src/lib.rs");
33646        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33647        std::fs::write(&source, source_body).unwrap();
33648        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33649        db.rebuild(root).unwrap();
33650        drop(db);
33651
33652        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33653            .unwrap()
33654            .value;
33655        std::thread::sleep(std::time::Duration::from_millis(20));
33656        std::fs::write(&source, source_body).unwrap();
33657        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33658        db.apply_changes(root).unwrap();
33659        drop(db);
33660
33661        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33662            .unwrap()
33663            .value;
33664        assert_eq!(
33665            first, second,
33666            "mtime-only source index churn must not invalidate the full-projection cache"
33667        );
33668    }
33669
33670    #[test]
33671    fn full_projection_source_watermark_ignores_session_markdown_churn() {
33672        // #gfullhot: the full-projection performance cache isolates code graph
33673        // and semantic-summary inputs. Current session evidence is measured by
33674        // the bounded real dataset, so unrelated task-doc edits must not force a
33675        // million-row full-projection rebuild.
33676        let dir = tempfile::tempdir().unwrap();
33677        let root = dir.path();
33678        std::fs::create_dir_all(root.join("src")).unwrap();
33679        std::fs::create_dir_all(root.join("tasks/software")).unwrap();
33680        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33681        std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
33682        let task_doc = root.join("tasks/software/tsift.md");
33683        std::fs::write(
33684            &task_doc,
33685            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
33686        )
33687        .unwrap();
33688        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33689        db.rebuild(root).unwrap();
33690        drop(db);
33691
33692        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33693            .unwrap()
33694            .value;
33695        std::fs::write(
33696            &task_doc,
33697            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
33698        )
33699        .unwrap();
33700        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33701            .unwrap()
33702            .value;
33703        assert_eq!(
33704            first, second,
33705            "session markdown churn must not invalidate the full-projection code/summary cache"
33706        );
33707    }
33708
33709    #[test]
33710    fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
33711        // #gfullhot: once a full-project projection is cached, repeated samples
33712        // with unchanged graph inputs must report zero source_graph_build and
33713        // projection_rows work even if indexed file mtimes changed.
33714        let dir = tempfile::tempdir().unwrap();
33715        let root = dir.path();
33716        std::fs::create_dir_all(root.join("src")).unwrap();
33717        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33718        let source = root.join("src/lib.rs");
33719        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33720        std::fs::write(&source, source_body).unwrap();
33721        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33722        db.rebuild(root).unwrap();
33723        drop(db);
33724
33725        let (_projection, _warnings, _phases, first_stats) =
33726            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33727        assert!(
33728            !first_stats.hit,
33729            "the first full-projection run should populate the cache"
33730        );
33731
33732        std::thread::sleep(std::time::Duration::from_millis(20));
33733        std::fs::write(&source, source_body).unwrap();
33734        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33735        db.apply_changes(root).unwrap();
33736        drop(db);
33737
33738        let (_projection, _warnings, phases, second_stats) =
33739            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33740        assert!(second_stats.hit, "mtime-only churn should still cache-hit");
33741        let source_graph_build = phases
33742            .iter()
33743            .find(|phase| phase.name == "full_projection.source_graph_build")
33744            .expect("cache hit must report source_graph_build");
33745        let projection_rows = phases
33746            .iter()
33747            .find(|phase| phase.name == "full_projection.projection_rows")
33748            .expect("cache hit must report projection_rows");
33749        assert_eq!(source_graph_build.duration_micros, 0);
33750        assert_eq!(projection_rows.duration_micros, 0);
33751    }
33752
33753    #[test]
33754    fn build_token_capped_preview_within_cap() {
33755        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 2", "}"];
33756        let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
33757        assert!(!capped.was_capped);
33758        assert_eq!(capped.preview.len(), 3);
33759        assert_eq!(capped.capped_end, 3);
33760    }
33761
33762    #[test]
33763    fn build_token_capped_preview_truncates_long_body() {
33764        let owned: Vec<String> = (0..200)
33765            .map(|i| format!("    let line_{i} = {i};"))
33766            .collect();
33767        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33768        let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
33769        assert!(capped.was_capped);
33770        assert!(capped.preview.len() < 200);
33771        assert!(capped.capped_end < 200);
33772        assert!(!capped.preview.is_empty());
33773    }
33774
33775    #[test]
33776    fn build_token_capped_preview_respects_start_offset() {
33777        let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
33778        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33779        let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
33780        assert!(capped.was_capped);
33781        assert!(capped.capped_end >= 50);
33782        assert!(capped.capped_end < 100);
33783        assert_eq!(capped.preview[0].line, 50);
33784    }
33785
33786    #[test]
33787    fn response_budget_body_token_cap_defaults() {
33788        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
33789        assert_eq!(budget.body_token_cap(), 1500);
33790
33791        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
33792        assert_eq!(budget.body_token_cap(), 500);
33793
33794        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
33795        assert_eq!(budget.body_token_cap(), 3000);
33796    }
33797
33798    #[test]
33799    fn build_token_capped_preview_empty_input() {
33800        let lines: Vec<&str> = vec![];
33801        let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
33802        assert!(!capped.was_capped);
33803        assert!(capped.preview.is_empty());
33804    }
33805
33806    #[test]
33807    fn build_token_capped_preview_single_long_line_fits() {
33808        let lines: Vec<&str> = vec!["short"];
33809        let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
33810        assert!(!capped.was_capped);
33811        assert_eq!(capped.preview.len(), 1);
33812        assert_eq!(capped.capped_end, 1);
33813    }
33814
33815    #[test]
33816    fn edge_index_replaces_from_id_to_id_with_positions() {
33817        let input = serde_json::json!({
33818            "nodes": [
33819                {"id": "symbol:src/lib.rs:foo"},
33820                {"id": "symbol:src/lib.rs:bar"},
33821                {"id": "symbol:src/lib.rs:baz"}
33822            ],
33823            "edges": [
33824                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
33825                {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
33826            ]
33827        });
33828        let result = edge_index_transform(input);
33829        let edges = result.get("edges").unwrap().as_array().unwrap();
33830        assert_eq!(edges.len(), 2);
33831        assert_eq!(edges[0]["from"], 0);
33832        assert_eq!(edges[0]["to"], 1);
33833        assert_eq!(edges[1]["from"], 1);
33834        assert_eq!(edges[1]["to"], 2);
33835        assert!(edges[0].get("from_id").is_none());
33836        assert!(edges[0].get("to_id").is_none());
33837    }
33838
33839    #[test]
33840    fn edge_index_preserves_unresolved_ids_as_strings() {
33841        let input = serde_json::json!({
33842            "nodes": [{"id": "symbol:src/lib.rs:foo"}],
33843            "edges": [
33844                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
33845            ]
33846        });
33847        let result = edge_index_transform(input);
33848        let edge = &result["edges"][0];
33849        assert_eq!(edge["from"], 0);
33850        assert_eq!(edge["to_id"], "symbol:other.rs:missing");
33851    }
33852
33853    #[test]
33854    fn edge_index_noop_without_nodes_and_edges() {
33855        let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
33856        let result = edge_index_transform(input);
33857        assert_eq!(result["report"]["entries"][0]["from_id"], "a");
33858    }
33859}
33860
33861// --- SQL introspection ---
33862
33863#[derive(Serialize)]
33864struct TableInfo {
33865    name: String,
33866    columns: Vec<ColumnInfo>,
33867    row_count: i64,
33868}
33869
33870#[derive(Serialize)]
33871struct ColumnInfo {
33872    name: String,
33873    #[serde(rename = "type")]
33874    col_type: String,
33875    notnull: bool,
33876    pk: bool,
33877    #[serde(skip_serializing_if = "Option::is_none")]
33878    default_value: Option<String>,
33879}
33880
33881/// Open a SQLite connection (read-only).
33882pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
33883    let conn = Connection::open_with_flags(
33884        path,
33885        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
33886    )
33887    .with_context(|| format!("opening database: {}", path.display()))?;
33888    Ok(conn)
33889}
33890
33891/// List all user tables with column metadata and row counts.
33892pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
33893    let mut stmt = conn.prepare(
33894        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
33895    )?;
33896    let table_names: Vec<String> = stmt
33897        .query_map([], |row| row.get(0))?
33898        .collect::<std::result::Result<Vec<_>, _>>()?;
33899
33900    let mut tables = Vec::new();
33901    for tbl in table_names {
33902        let columns = table_columns(conn, &tbl)?;
33903        let row_count: i64 =
33904            conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
33905                row.get(0)
33906            })?;
33907        tables.push(TableInfo {
33908            name: tbl,
33909            columns,
33910            row_count,
33911        });
33912    }
33913    Ok(tables)
33914}
33915
33916/// Get column metadata for a single table.
33917pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
33918    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
33919    let cols = stmt
33920        .query_map([], |row| {
33921            Ok(ColumnInfo {
33922                name: row.get(1)?,
33923                col_type: row.get::<_, String>(2).unwrap_or_default(),
33924                notnull: row.get::<_, bool>(3).unwrap_or(false),
33925                pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
33926                default_value: row.get(4)?,
33927            })
33928        })?
33929        .collect::<std::result::Result<Vec<_>, _>>()?;
33930    Ok(cols)
33931}
33932
33933/// Execute an arbitrary SQL query and return rows as JSON values.
33934pub(crate) fn execute_query(
33935    conn: &Connection,
33936    sql: &str,
33937) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
33938    let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
33939    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
33940    let col_count = col_names.len();
33941
33942    let mut rows = Vec::new();
33943    let mut query_rows = stmt.query([])?;
33944    while let Some(row) = query_rows.next()? {
33945        let mut vals = Vec::with_capacity(col_count);
33946        for i in 0..col_count {
33947            let val = match row.get_ref(i)? {
33948                rusqlite::types::ValueRef::Null => serde_json::Value::Null,
33949                rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
33950                rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
33951                rusqlite::types::ValueRef::Text(s) => {
33952                    serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
33953                }
33954                rusqlite::types::ValueRef::Blob(b) => {
33955                    serde_json::Value::String(format!("<blob {} bytes>", b.len()))
33956                }
33957            };
33958            vals.push(val);
33959        }
33960        rows.push(vals);
33961    }
33962    Ok((col_names, rows))
33963}
33964
33965#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33966enum DigestRunnerKind {
33967    Test,
33968    Log,
33969}
33970
33971impl DigestRunnerKind {
33972    fn parse(raw: &str) -> Result<Self> {
33973        match raw.trim().to_ascii_lowercase().as_str() {
33974            "test" => Ok(Self::Test),
33975            "log" => Ok(Self::Log),
33976            other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
33977        }
33978    }
33979
33980    fn as_str(self) -> &'static str {
33981        match self {
33982            Self::Test => "test",
33983            Self::Log => "log",
33984        }
33985    }
33986}
33987
33988/// Simple shell word splitting (handles single and double quotes).
33989pub(crate) fn shell_split(s: &str) -> Vec<&str> {
33990    let mut parts = Vec::new();
33991    let mut i = 0;
33992    let bytes = s.as_bytes();
33993    while i < bytes.len() {
33994        // Skip whitespace
33995        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
33996            i += 1;
33997        }
33998        if i >= bytes.len() {
33999            break;
34000        }
34001        let start = i;
34002        if bytes[i] == b'"' || bytes[i] == b'\'' {
34003            let quote = bytes[i];
34004            i += 1;
34005            while i < bytes.len() && bytes[i] != quote {
34006                i += 1;
34007            }
34008            if i < bytes.len() {
34009                i += 1; // closing quote
34010            }
34011        } else {
34012            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
34013                i += 1;
34014            }
34015        }
34016        parts.push(&s[start..i]);
34017    }
34018    parts
34019}
34020
34021/// Quote a string for shell if it contains special characters.
34022pub(crate) fn shell_quote(s: &str) -> String {
34023    // Strip existing quotes
34024    let unquoted =
34025        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
34026            &s[1..s.len() - 1]
34027        } else {
34028            s
34029        };
34030
34031    if unquoted
34032        .chars()
34033        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
34034    {
34035        format!("\"{}\"", unquoted)
34036    } else {
34037        format!(
34038            "\"{}\"",
34039            unquoted.replace('\\', "\\\\").replace('"', "\\\"")
34040        )
34041    }
34042}
34043
34044fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
34045    sift::SearchCoverageSnapshot {
34046        mode: sift::SearchCoverageMode::Sealed,
34047        total_sector_count: 0,
34048        mounted_sector_count: 0,
34049        reused_sector_count: 0,
34050        dirty_sector_count: 0,
34051        completed_dirty_sector_count: 0,
34052        rebuilding_sector_count: 0,
34053        resumed_sector_count: 0,
34054        active_rebuild: None,
34055    }
34056}
34057
34058fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
34059    let total_sector_count = responses
34060        .iter()
34061        .map(|response| response.coverage.total_sector_count)
34062        .sum();
34063    let mounted_sector_count = responses
34064        .iter()
34065        .map(|response| response.coverage.mounted_sector_count)
34066        .sum();
34067    let reused_sector_count = responses
34068        .iter()
34069        .map(|response| response.coverage.reused_sector_count)
34070        .sum();
34071    let dirty_sector_count = responses
34072        .iter()
34073        .map(|response| response.coverage.dirty_sector_count)
34074        .sum();
34075    let completed_dirty_sector_count = responses
34076        .iter()
34077        .map(|response| response.coverage.completed_dirty_sector_count)
34078        .sum();
34079    let rebuilding_sector_count = responses
34080        .iter()
34081        .map(|response| response.coverage.rebuilding_sector_count)
34082        .sum();
34083    let resumed_sector_count = responses
34084        .iter()
34085        .map(|response| response.coverage.resumed_sector_count)
34086        .sum();
34087
34088    let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
34089        sift::SearchCoverageMode::Sealed
34090    } else if completed_dirty_sector_count > 0
34091        || rebuilding_sector_count > 0
34092        || resumed_sector_count > 0
34093    {
34094        sift::SearchCoverageMode::Converging
34095    } else {
34096        sift::SearchCoverageMode::Frontier
34097    };
34098
34099    sift::SearchCoverageSnapshot {
34100        mode,
34101        total_sector_count,
34102        mounted_sector_count,
34103        reused_sector_count,
34104        dirty_sector_count,
34105        completed_dirty_sector_count,
34106        rebuilding_sector_count,
34107        resumed_sector_count,
34108        active_rebuild: responses
34109            .iter()
34110            .find_map(|response| response.coverage.active_rebuild.clone()),
34111    }
34112}
34113
34114fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
34115    sift::SearchResponse {
34116        strategy: strategy.to_string(),
34117        root: root.display().to_string(),
34118        indexed_artifacts: 0,
34119        skipped_artifacts: 0,
34120        coverage: empty_search_coverage(),
34121        hits: Vec::new(),
34122    }
34123}
34124
34125fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
34126    for hit in &mut response.hits {
34127        let path = Path::new(&hit.path);
34128        if path.is_relative() {
34129            hit.path = search_root.join(path).display().to_string();
34130        }
34131    }
34132}
34133
34134fn merge_search_responses(
34135    root: &Path,
34136    strategy: &str,
34137    limit: usize,
34138    responses: Vec<sift::SearchResponse>,
34139) -> sift::SearchResponse {
34140    let indexed_artifacts = responses
34141        .iter()
34142        .map(|response| response.indexed_artifacts)
34143        .sum();
34144    let skipped_artifacts = responses
34145        .iter()
34146        .map(|response| response.skipped_artifacts)
34147        .sum();
34148    let coverage = if responses.is_empty() {
34149        empty_search_coverage()
34150    } else {
34151        aggregate_search_coverage(&responses)
34152    };
34153    let mut hits: Vec<sift::SearchHit> = responses
34154        .into_iter()
34155        .flat_map(|response| response.hits)
34156        .collect();
34157    hits.sort_by(|left, right| {
34158        right
34159            .score
34160            .partial_cmp(&left.score)
34161            .unwrap_or(Ordering::Equal)
34162            .then_with(|| left.path.cmp(&right.path))
34163            .then_with(|| left.location.cmp(&right.location))
34164    });
34165    hits.truncate(limit);
34166    for (rank, hit) in hits.iter_mut().enumerate() {
34167        hit.rank = rank + 1;
34168    }
34169
34170    sift::SearchResponse {
34171        strategy: strategy.to_string(),
34172        root: root.display().to_string(),
34173        indexed_artifacts,
34174        skipped_artifacts,
34175        coverage,
34176        hits,
34177    }
34178}
34179
34180pub(crate) fn federated_sift_search(
34181    root: &Path,
34182    cache_dir: &Path,
34183    query: &str,
34184    limit: usize,
34185    timeout_secs: u64,
34186    strategy: &str,
34187    fts_index_fresh: Option<bool>,
34188) -> Result<sift::SearchResponse> {
34189    let targets = resolve_search_index_targets(root, root, None, true)?;
34190    if targets.is_empty() {
34191        if config::Config::submodule_dirs(root)?.is_empty() {
34192            return run_search_with_timeout(
34193                root,
34194                cache_dir,
34195                query,
34196                limit,
34197                timeout_secs,
34198                strategy,
34199                &[],
34200                fts_index_fresh,
34201            );
34202        }
34203        return Ok(empty_search_response(root, strategy));
34204    }
34205
34206    let mut responses = Vec::with_capacity(targets.len());
34207    for target in &targets {
34208        let mut response = run_search_with_timeout(
34209            &target.source_root,
34210            cache_dir,
34211            query,
34212            limit,
34213            timeout_secs,
34214            strategy,
34215            std::slice::from_ref(target),
34216            fts_index_fresh,
34217        )?;
34218        absolutize_search_hit_paths(&mut response, &target.source_root);
34219        response.root = root.display().to_string();
34220        responses.push(response);
34221    }
34222
34223    Ok(merge_search_responses(root, strategy, limit, responses))
34224}
34225
34226/// Federated symbol search across every scoped `.tsift/indexes/<scope>/index.db`
34227/// in the workspace. Per-scope tagpath annotation runs inside the per-scope
34228/// loop so each scope's adapter resolves against its own `.naming.toml` /
34229/// `.naming/index.json` (the workspace root usually has no tagpath of its
34230/// own). The merged `TagpathAnnotationDiagnostic` reports `loaded=true` when
34231/// at least one scope loaded, and `stale=true` with the first stale reason
34232/// when any scope was stale.
34233pub(crate) fn federated_symbol_search(
34234    root: &std::path::Path,
34235    query: &str,
34236    limit: usize,
34237    tagpath_opts: &TagpathSearchOpts,
34238) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
34239    let cfg = config::Config::load(root)?;
34240    let submodules = config::Config::submodule_dirs(root)?;
34241    let mut all_hits: Vec<index::SymbolHit> = Vec::new();
34242    let mut combined = TagpathAnnotationDiagnostic::default();
34243    for scope in &submodules {
34244        if !cfg.federation_for_scope(scope) {
34245            continue;
34246        }
34247        let db_path = cfg.db_path_for(root, &scope.id);
34248        if !db_path.exists() {
34249            continue;
34250        }
34251        let db = index::IndexDb::open_read_only(&db_path)?;
34252        let mut hits = db.symbol_search(query, limit)?;
34253        let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
34254        combined.loaded |= diag.loaded;
34255        if diag.stale && !combined.stale {
34256            combined.stale = true;
34257            combined.reason = diag.reason;
34258        }
34259        all_hits.append(&mut hits);
34260    }
34261    all_hits.sort_by(|a, b| {
34262        b.score
34263            .partial_cmp(&a.score)
34264            .unwrap_or(std::cmp::Ordering::Equal)
34265    });
34266    all_hits.truncate(limit);
34267    Ok((all_hits, combined))
34268}
34269
34270#[derive(Debug, Deserialize)]
34271#[serde(tag = "type", rename_all = "lowercase")]
34272enum RipgrepJsonEvent {
34273    Match {
34274        data: RipgrepMatchData,
34275    },
34276    #[serde(other)]
34277    Other,
34278}
34279
34280#[derive(Debug, Deserialize)]
34281struct RipgrepMatchData {
34282    path: RipgrepTextField,
34283    lines: RipgrepTextField,
34284    line_number: Option<usize>,
34285}
34286
34287#[derive(Debug, Deserialize)]
34288struct RipgrepTextField {
34289    text: Option<String>,
34290}
34291
34292pub(crate) fn federated_exact_search(
34293    root: &Path,
34294    query: &str,
34295    limit: usize,
34296    timeout_secs: u64,
34297) -> Result<sift::SearchResponse> {
34298    let cfg = config::Config::load(root)?;
34299    let mut responses = Vec::new();
34300    for scope in config::Config::submodule_dirs(root)? {
34301        if !cfg.federation_for_scope(&scope) {
34302            continue;
34303        }
34304        let mut response =
34305            run_exact_search_with_timeout(std::slice::from_ref(&scope.source_root), query, limit, timeout_secs)?;
34306        absolutize_search_hit_paths(&mut response, &scope.source_root);
34307        response.root = root.display().to_string();
34308        responses.push(response);
34309    }
34310
34311    Ok(merge_search_responses(root, "exact", limit, responses))
34312}
34313
34314pub(crate) fn run_sift_search(
34315    search_path: &Path,
34316    cache_dir: &Path,
34317    query: &str,
34318    limit: usize,
34319    strategy: &str,
34320    // #015t Phase 4b — the caller's already-known FTS index freshness:
34321    //   `Some(true)`  — caller (cmd_search after precheck+autoindex) proved fresh;
34322    //                   use FTS without re-walking the tree.
34323    //   `Some(false)` — caller proved stale/degraded (e.g. read-only writer lock);
34324    //                   skip FTS, serve live results via the TokenIndex fallback.
34325    //   `None`        — unknown (direct programmatic callers); inspect here.
34326    // This drops the redundant `inspect_read_only` walk on the normal CLI path,
34327    // where `precheck_search_indexes` already established freshness.
34328    fts_index_fresh: Option<bool>,
34329) -> Result<sift::SearchResponse> {
34330    // #015t Phase 4 cutover: the `index.db` FTS5 path is now the DEFAULT for
34331    // lexical search. The normal search flow runs `precheck_search_indexes` with
34332    // autoindex first, so a fresh root `index.db` is guaranteed present before we
34333    // get here; the FTS5 BM25 path supersedes the parallel JSON `TokenIndex`
34334    // (which never rebuilt on content change — staleness was keyed on file
34335    // existence only). Ranking shifts from substring-position to BM25 by design;
34336    // the Phase 3 soundness gate proved candidate coverage (FTS ⊇ TokenIndex).
34337    //
34338    // The JSON `TokenIndex` is demoted to a FALLBACK for the only remaining cases
34339    // that reach here without a fresh root index.db: an un-indexed root reached
34340    // with `--no-autoindex` (the normal precheck degrades a missing index to exact
34341    // search, not lexical), a stale/degraded index (live results via TokenIndex),
34342    // and direct programmatic callers. `TSIFT_FTS_SEARCH=0` (`0`/`false`/`no`/`off`)
34343    // forces that legacy path as a transition escape hatch. Both the in-process
34344    // (timeout=0) and `__search-worker` subprocess routes pass through here; the
34345    // worker inherits the env var and is handed the freshness verdict explicitly.
34346    if !fts_search_forced_off() {
34347        let db_path = search_path.join(".tsift/index.db");
34348        let use_fts = match fts_index_fresh {
34349            Some(fresh) => fresh && db_path.exists(),
34350            None => db_path.exists() && index_db_is_fresh_for_fts(&db_path, search_path),
34351        };
34352        if use_fts {
34353            return sift::fts_search(&db_path, search_path, query, limit)
34354                .context("index.db FTS5 search failed");
34355        }
34356    }
34357
34358    let engine = Sift::builder().with_cache_dir(cache_dir).build();
34359    let options = SearchOptions::default()
34360        .with_limit(limit)
34361        .with_strategy(strategy.to_string());
34362    let input = SearchInput::new(search_path, query).with_options(options);
34363    engine.search(input).context("sift search failed")
34364}
34365
34366/// #015t Phase 4 — the FTS5 `index.db` path is trustworthy only when the index is
34367/// **openable AND fresh** for the search root. A missing/corrupt index.db (e.g. an
34368/// empty placeholder) or a **stale** one (e.g. held open by a concurrent writer so
34369/// autoindex degraded to read-only) falls back to the live `TokenIndex` path — so a
34370/// search never returns content the index has not caught up to. The normal flow's
34371/// `precheck_search_indexes` + autoindex makes this true in the common case;
34372/// re-inspecting here is a redundant tree-walk that #015t Phase 4b can replace by
34373/// threading the precheck's freshness result through to this call.
34374fn index_db_is_fresh_for_fts(db_path: &Path, search_path: &Path) -> bool {
34375    match index::IndexDb::inspect_read_only(db_path, search_path, false) {
34376        Ok(inspection) => {
34377            inspection.summary.new + inspection.summary.modified + inspection.summary.deleted == 0
34378        }
34379        Err(_) => false,
34380    }
34381}
34382
34383/// #015t Phase 4 — whether the operator has forced lexical search back onto the
34384/// legacy JSON `TokenIndex` path via `TSIFT_FTS_SEARCH` set to a falsy value
34385/// (`0`/`false`/`no`/`off`). The FTS5 `index.db` path is the default; this is the
34386/// transition escape hatch. Any other value (or unset) keeps the FTS5 default.
34387fn fts_search_forced_off() -> bool {
34388    std::env::var("TSIFT_FTS_SEARCH")
34389        .map(|value| fts_flag_value_disabled(&value))
34390        .unwrap_or(false)
34391}
34392
34393/// Pure parser for a falsy `TSIFT_FTS_SEARCH` value (factored out so the rules
34394/// can be unit-tested without mutating process env).
34395fn fts_flag_value_disabled(value: &str) -> bool {
34396    matches!(
34397        value.trim().to_ascii_lowercase().as_str(),
34398        "0" | "false" | "no" | "off"
34399    )
34400}
34401
34402fn exact_search_timeout_message(timeout_secs: u64) -> String {
34403    format!(
34404        "tsift search timed out after {}s (strategy: exact). \
34405         Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
34406        timeout_secs
34407    )
34408}
34409
34410fn exact_search_command(search_paths: &[PathBuf], query: &str) -> Command {
34411    let mut command = Command::new("rg");
34412    command
34413        .arg("--json")
34414        .arg("--fixed-strings")
34415        .arg("--line-number")
34416        .arg("--hidden")
34417        .arg("--")
34418        .arg(query);
34419    if search_paths.is_empty() {
34420        command.arg(Path::new("."));
34421    } else {
34422        command.args(search_paths);
34423    }
34424    command
34425}
34426
34427fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
34428    let observed_unix_secs = SystemTime::now()
34429        .duration_since(UNIX_EPOCH)
34430        .unwrap_or_default()
34431        .as_secs() as i64;
34432    let modified_unix_secs = fs::metadata(path)
34433        .ok()
34434        .and_then(|metadata| metadata.modified().ok())
34435        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
34436        .map(|duration| duration.as_secs() as i64);
34437    sift::ArtifactFreshness {
34438        observed_unix_secs,
34439        modified_unix_secs,
34440    }
34441}
34442
34443fn parse_exact_search_output(
34444    search_path: &Path,
34445    limit: usize,
34446    raw: &str,
34447) -> Result<sift::SearchResponse> {
34448    if limit == 0 {
34449        return Ok(sift::SearchResponse {
34450            strategy: "exact".to_string(),
34451            root: search_path.display().to_string(),
34452            indexed_artifacts: 0,
34453            skipped_artifacts: 0,
34454            coverage: empty_search_coverage(),
34455            hits: Vec::new(),
34456        });
34457    }
34458
34459    let mut hits = Vec::new();
34460    for line in raw.lines() {
34461        let event: RipgrepJsonEvent =
34462            serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
34463        let RipgrepJsonEvent::Match { data } = event else {
34464            continue;
34465        };
34466        let Some(path_text) = data.path.text else {
34467            continue;
34468        };
34469        let Some(lines_text) = data.lines.text else {
34470            continue;
34471        };
34472        let path = PathBuf::from(path_text);
34473        let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
34474        let rank = hits.len() + 1;
34475        hits.push(sift::SearchHit {
34476            artifact_id: format!(
34477                "exact:{}:{}:{}",
34478                path.display(),
34479                data.line_number.unwrap_or(0),
34480                rank
34481            ),
34482            artifact_kind: sift::ContextArtifactKind::File,
34483            path: path.display().to_string(),
34484            rank,
34485            score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
34486            confidence: sift::ScoreConfidence::High,
34487            location: data.line_number.map(|line| format!("line {}", line)),
34488            snippet: snippet.clone(),
34489            provenance: sift::ArtifactProvenance {
34490                adapter: sift::AcquisitionAdapterKind::FileSystem,
34491                source: "ripgrep -F".to_string(),
34492                synthetic: false,
34493            },
34494            freshness: exact_search_file_timestamp(&path),
34495            budget: sift::ArtifactBudget::from_text(&snippet, 1),
34496        });
34497        if hits.len() >= limit {
34498            break;
34499        }
34500    }
34501
34502    Ok(sift::SearchResponse {
34503        strategy: "exact".to_string(),
34504        root: search_path.display().to_string(),
34505        indexed_artifacts: hits.len(),
34506        skipped_artifacts: 0,
34507        coverage: empty_search_coverage(),
34508        hits,
34509    })
34510}
34511
34512fn exact_search_response_from_process(
34513    search_path: &Path,
34514    limit: usize,
34515    status: std::process::ExitStatus,
34516    stdout: &[u8],
34517    stderr: &[u8],
34518) -> Result<sift::SearchResponse> {
34519    if !status.success() && status.code() != Some(1) {
34520        let message = String::from_utf8_lossy(stderr);
34521        let trimmed = message.trim();
34522        if trimmed.is_empty() {
34523            bail!("ripgrep exact search exited with status {}", status);
34524        }
34525        bail!("{}", trimmed);
34526    }
34527
34528    let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
34529    parse_exact_search_output(search_path, limit, &raw)
34530}
34531
34532fn run_exact_search(search_paths: &[PathBuf], query: &str, limit: usize) -> Result<sift::SearchResponse> {
34533    let output = exact_search_command(search_paths, query)
34534        .output()
34535        .context("running exact search with ripgrep")?;
34536    let root_display = search_paths
34537        .first()
34538        .map(|p| p.as_path())
34539        .unwrap_or_else(|| Path::new("."));
34540    exact_search_response_from_process(
34541        root_display,
34542        limit,
34543        output.status,
34544        &output.stdout,
34545        &output.stderr,
34546    )
34547}
34548
34549pub(crate) fn run_exact_search_with_timeout(
34550    search_paths: &[PathBuf],
34551    query: &str,
34552    limit: usize,
34553    timeout_secs: u64,
34554) -> Result<sift::SearchResponse> {
34555    if timeout_secs == 0 {
34556        return run_exact_search(search_paths, query, limit);
34557    }
34558
34559    let mut child = exact_search_command(search_paths, query)
34560        .stdin(Stdio::null())
34561        .stdout(Stdio::piped())
34562        .stderr(Stdio::piped())
34563        .spawn()
34564        .context("spawning timed exact search worker")?;
34565
34566    let timeout = Duration::from_secs(timeout_secs);
34567    let status = wait_for_child_exit(&mut child, timeout)
34568        .context("waiting for timed exact search worker")?;
34569    if status.is_none() {
34570        let _ = child.kill();
34571        let _ = child.wait();
34572        bail!("{}", exact_search_timeout_message(timeout_secs));
34573    }
34574
34575    let status = status.unwrap();
34576    let stdout = read_child_stdout(&mut child)?;
34577    let stderr = read_child_stderr(&mut child)?;
34578    let root_display = search_paths
34579        .first()
34580        .map(|p| p.as_path())
34581        .unwrap_or_else(|| Path::new("."));
34582    exact_search_response_from_process(
34583        root_display,
34584        limit,
34585        status,
34586        stdout.as_bytes(),
34587        stderr.as_bytes(),
34588    )
34589}
34590
34591#[allow(clippy::too_many_arguments)]
34592pub(crate) fn run_search_with_timeout(
34593    search_path: &Path,
34594    cache_dir: &Path,
34595    query: &str,
34596    limit: usize,
34597    timeout_secs: u64,
34598    strategy: &str,
34599    search_targets: &[SearchIndexTarget],
34600    // #015t Phase 4b — FTS index freshness verdict forwarded to the worker so it
34601    // skips the redundant `inspect_read_only` walk (see `run_sift_search`).
34602    fts_index_fresh: Option<bool>,
34603) -> Result<sift::SearchResponse> {
34604    if timeout_secs == 0 {
34605        return run_sift_search(search_path, cache_dir, query, limit, strategy, fts_index_fresh);
34606    }
34607
34608    let output_path = next_search_worker_output_path();
34609    let mut command = Command::new(
34610        std::env::current_exe().context("resolving tsift executable for timed search")?,
34611    );
34612    command
34613        .arg("__search-worker")
34614        .arg("--path")
34615        .arg(search_path)
34616        .arg("--cache-dir")
34617        .arg(cache_dir)
34618        .arg("--query")
34619        .arg(query)
34620        .arg("--limit")
34621        .arg(limit.to_string())
34622        .arg("--strategy")
34623        .arg(strategy)
34624        .arg("--output")
34625        .arg(&output_path);
34626    if let Some(fresh) = fts_index_fresh {
34627        command.arg("--fts-index-fresh").arg(fresh.to_string());
34628    }
34629    let mut child = command
34630        .stdin(Stdio::null())
34631        .stdout(Stdio::null())
34632        .stderr(Stdio::piped())
34633        .spawn()
34634        .context("spawning timed sift search worker")?;
34635
34636    let timeout = Duration::from_secs(timeout_secs);
34637    let status =
34638        wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
34639    if status.is_none() {
34640        let _ = child.kill();
34641        let _ = child.wait();
34642        let _ = fs::remove_file(&output_path);
34643        bail!(
34644            "{}",
34645            search_timeout_message(timeout_secs, strategy, search_targets)?
34646        );
34647    }
34648
34649    let status = status.unwrap();
34650    let stderr = read_child_stderr(&mut child)?;
34651    if !status.success() {
34652        let _ = fs::remove_file(&output_path);
34653        let message = stderr.trim();
34654        if message.is_empty() {
34655            bail!("sift search worker exited with status {}", status);
34656        }
34657        bail!("{}", message);
34658    }
34659
34660    let raw = fs::read_to_string(&output_path)
34661        .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
34662    let _ = fs::remove_file(&output_path);
34663    serde_json::from_str(&raw).context("parsing search worker output")
34664}
34665
34666fn next_search_worker_output_path() -> PathBuf {
34667    let stamp = SystemTime::now()
34668        .duration_since(UNIX_EPOCH)
34669        .unwrap_or_default()
34670        .as_nanos();
34671    std::env::temp_dir().join(format!(
34672        "tsift-search-{}-{}.json",
34673        std::process::id(),
34674        stamp
34675    ))
34676}
34677
34678fn wait_for_child_exit(
34679    child: &mut std::process::Child,
34680    timeout: Duration,
34681) -> Result<Option<std::process::ExitStatus>> {
34682    let started = Instant::now();
34683    loop {
34684        if let Some(status) = child.try_wait()? {
34685            return Ok(Some(status));
34686        }
34687        if started.elapsed() >= timeout {
34688            return Ok(None);
34689        }
34690        let remaining = timeout.saturating_sub(started.elapsed());
34691        std::thread::sleep(remaining.min(Duration::from_millis(10)));
34692    }
34693}
34694
34695fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
34696    let mut stderr = String::new();
34697    if let Some(mut pipe) = child.stderr.take() {
34698        pipe.read_to_string(&mut stderr)
34699            .context("reading search worker stderr")?;
34700    }
34701    Ok(stderr)
34702}
34703
34704fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
34705    let mut stdout = String::new();
34706    if let Some(mut pipe) = child.stdout.take() {
34707        pipe.read_to_string(&mut stdout)
34708            .context("reading search worker stdout")?;
34709    }
34710    Ok(stdout)
34711}
34712
34713pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
34714    if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
34715        fs::write(&path, std::process::id().to_string())
34716            .with_context(|| format!("writing search worker pid file: {path}"))?;
34717    }
34718    if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
34719        let delay_ms = ms
34720            .parse::<u64>()
34721            .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
34722        std::thread::sleep(Duration::from_millis(delay_ms));
34723    }
34724    Ok(())
34725}
34726
34727#[cfg(test)]
34728thread_local! {
34729    static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
34730}
34731
34732#[cfg(test)]
34733enum SearchPostPrecheckLockMode {
34734    RollbackJournal,
34735    Wal,
34736}
34737
34738#[cfg(test)]
34739struct SearchPostPrecheckLockHook {
34740    db_path: PathBuf,
34741    mode: SearchPostPrecheckLockMode,
34742}
34743
34744#[cfg(test)]
34745struct SearchPostPrecheckLockGuard;
34746
34747#[cfg(test)]
34748impl Drop for SearchPostPrecheckLockGuard {
34749    fn drop(&mut self) {
34750        SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34751            hook.borrow_mut().take();
34752        });
34753    }
34754}
34755
34756#[cfg(test)]
34757fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34758    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
34759}
34760
34761#[cfg(test)]
34762fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34763    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
34764}
34765
34766#[cfg(test)]
34767fn install_search_post_precheck_lock_hook(
34768    db_path: PathBuf,
34769    mode: SearchPostPrecheckLockMode,
34770) -> SearchPostPrecheckLockGuard {
34771    SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34772        assert!(
34773            hook.borrow().is_none(),
34774            "search post-precheck lock hook already installed"
34775        );
34776        *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
34777    });
34778    SearchPostPrecheckLockGuard
34779}
34780
34781#[cfg(test)]
34782pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34783    let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
34784        return Ok(());
34785    };
34786    let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
34787    std::thread::spawn(move || {
34788        let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
34789        match hook.mode {
34790            SearchPostPrecheckLockMode::RollbackJournal => {
34791                conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
34792                    .expect("acquiring rollback-journal hook lock");
34793                fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
34794                    .expect("writing rollback journal marker");
34795            }
34796            SearchPostPrecheckLockMode::Wal => {
34797                conn.execute_batch(
34798                    "PRAGMA journal_mode=WAL;
34799                     PRAGMA wal_autocheckpoint=0;
34800                     CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
34801                     INSERT INTO search_wal_lock_probe DEFAULT VALUES;
34802                     PRAGMA locking_mode=EXCLUSIVE;
34803                     BEGIN EXCLUSIVE;",
34804                )
34805                .expect("acquiring WAL hook lock");
34806                assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
34807            }
34808        }
34809        ready_tx.send(()).expect("signaling search lock hook");
34810        std::thread::sleep(Duration::from_millis(200));
34811        drop(conn);
34812        let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
34813    });
34814    ready_rx
34815        .recv_timeout(Duration::from_secs(1))
34816        .context("waiting for search post-precheck lock hook")?;
34817    Ok(())
34818}
34819
34820#[cfg(not(test))]
34821pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34822    Ok(())
34823}