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(dir.path(), "claudescore-3", 5, 0).unwrap();
27853
27854        assert_eq!(response.strategy, "exact");
27855        assert_eq!(response.hits.len(), 1);
27856        assert!(response.hits[0].path.ends_with("notes.txt"));
27857        assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
27858        assert!(response.hits[0].snippet.contains("claudescore-3"));
27859    }
27860
27861    #[test]
27862    fn exact_search_skips_stale_index_precheck() {
27863        let dir = setup_graph_index();
27864        std::thread::sleep(std::time::Duration::from_millis(50));
27865        std::fs::write(
27866            dir.path().join("main.rs"),
27867            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
27868        )
27869        .unwrap();
27870
27871        let result = cmd_search(
27872            "println!(\"updated\")".to_string(),
27873            Some(dir.path().to_path_buf()),
27874            5,
27875            Some("exact".to_string()),
27876            None,
27877            false,
27878            false,
27879            false,
27880            0,
27881            false,
27882            false,
27883            false,
27884            false,
27885            false,
27886            false,
27887            false,
27888        );
27889
27890        assert!(result.is_ok());
27891    }
27892
27893    #[test]
27894    fn workspace_exact_search_does_not_require_shared_root_index() {
27895        let dir = setup_workspace();
27896        cmd_index(
27897            dir.path(),
27898            false,
27899            false,
27900            false,
27901            false,
27902            false,
27903            true,
27904            None,
27905            false,
27906            false,
27907            false,
27908            false,
27909            false,
27910            false,
27911        )
27912        .unwrap();
27913
27914        let result = cmd_search(
27915            "alpha_helper".to_string(),
27916            Some(dir.path().to_path_buf()),
27917            5,
27918            Some("exact".to_string()),
27919            None,
27920            false,
27921            false,
27922            false,
27923            0,
27924            false,
27925            false,
27926            false,
27927            false,
27928            false,
27929            false,
27930            false,
27931        );
27932
27933        assert!(result.is_ok());
27934        assert!(!dir.path().join(".tsift/index.db").exists());
27935    }
27936
27937    #[test]
27938    fn identifier_like_query_prefers_exact_search() {
27939        assert!(query_prefers_exact_search("claudescore-3"));
27940        assert!(query_prefers_exact_search("alpha_helper"));
27941        assert!(query_prefers_exact_search("src/main.rs"));
27942        assert!(query_prefers_exact_search("crate::module"));
27943        assert!(!query_prefers_exact_search("authenticate"));
27944        assert!(!query_prefers_exact_search("fn main"));
27945        assert!(!query_prefers_exact_search("."));
27946    }
27947
27948    #[test]
27949    fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
27950        assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
27951        assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
27952        assert_eq!(
27953            resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
27954            "hybrid"
27955        );
27956    }
27957
27958    #[test]
27959    fn workspace_identifier_like_search_auto_uses_exact_backend() {
27960        let dir = setup_workspace();
27961        cmd_index(
27962            dir.path(),
27963            false,
27964            false,
27965            false,
27966            false,
27967            false,
27968            true,
27969            None,
27970            false,
27971            false,
27972            false,
27973            false,
27974            false,
27975            false,
27976        )
27977        .unwrap();
27978
27979        let result = cmd_search(
27980            "alpha_helper".to_string(),
27981            Some(dir.path().to_path_buf()),
27982            5,
27983            None,
27984            None,
27985            false,
27986            false,
27987            false,
27988            0,
27989            false,
27990            false,
27991            false,
27992            false,
27993            false,
27994            false,
27995            false,
27996        );
27997
27998        assert!(result.is_ok());
27999        assert!(!dir.path().join(".tsift/index.db").exists());
28000    }
28001
28002    #[test]
28003    fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
28004        let dir = setup_graph_index();
28005        let nested = dir.path().join("src/nested");
28006        std::fs::create_dir_all(&nested).unwrap();
28007        std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
28008
28009        let result = cmd_index(
28010            &nested, false, false, false, false, false, false, None, false, false, false, false,
28011            false, false,
28012        );
28013
28014        assert!(result.is_ok());
28015        assert!(dir.path().join(".tsift/index.db").exists());
28016        assert!(!nested.join(".tsift/index.db").exists());
28017    }
28018
28019    #[test]
28020    fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
28021        let dir = setup_workspace();
28022        let nested = dir.path().join("docs/nested");
28023        std::fs::create_dir_all(&nested).unwrap();
28024
28025        let result = cmd_index(
28026            &nested, false, false, false, false, false, true, None, false, false, false, false,
28027            false, false,
28028        );
28029
28030        let cfg = config::Config::load(dir.path()).unwrap();
28031
28032        assert!(result.is_ok());
28033        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28034        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28035    }
28036
28037    #[test]
28038    fn status_cmd_autoindexes_missing_workspace_scopes() {
28039        let dir = setup_workspace();
28040        let cfg = config::Config::load(dir.path()).unwrap();
28041        let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
28042        let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
28043        let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
28044        alpha_db.apply_changes(&alpha.source_root).unwrap();
28045
28046        let beta_db_path = cfg.db_path_for(dir.path(), "beta");
28047        assert!(!beta_db_path.exists());
28048
28049        cmd_status(
28050            dir.path(),
28051            StatusCommandOptions {
28052                fix: false,
28053                no_fix: false,
28054                json_output: true,
28055                compact: false,
28056                pretty: false,
28057                terse: false,
28058                schema: false,
28059            },
28060        )
28061        .unwrap();
28062
28063        assert!(beta_db_path.exists());
28064        let report = status::check_status(dir.path()).unwrap();
28065        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28066    }
28067
28068    #[test]
28069    fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
28070        let dir = setup_workspace();
28071        let cfg = config::Config::load(dir.path()).unwrap();
28072
28073        cmd_status(
28074            dir.path(),
28075            StatusCommandOptions {
28076                fix: false,
28077                no_fix: false,
28078                json_output: true,
28079                compact: false,
28080                pretty: false,
28081                terse: false,
28082                schema: false,
28083            },
28084        )
28085        .unwrap();
28086
28087        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28088        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28089        let report = status::check_status(dir.path()).unwrap();
28090        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28091    }
28092
28093    #[test]
28094    fn status_cmd_fix_refreshes_stale_index() {
28095        let dir = setup_graph_index();
28096        std::thread::sleep(std::time::Duration::from_millis(50));
28097        std::fs::write(
28098            dir.path().join("main.rs"),
28099            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28100        )
28101        .unwrap();
28102
28103        let report = status::check_status(dir.path()).unwrap();
28104        assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
28105
28106        cmd_status(
28107            dir.path(),
28108            StatusCommandOptions {
28109                fix: false,
28110                no_fix: false,
28111                json_output: true,
28112                compact: false,
28113                pretty: false,
28114                terse: false,
28115                schema: false,
28116            },
28117        )
28118        .unwrap();
28119
28120        let report = status::check_status(dir.path()).unwrap();
28121        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28122    }
28123
28124    #[test]
28125    fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
28126        let dir = setup_graph_index();
28127        let db_path = dir.path().join(".tsift/index.db");
28128        let _lock = hold_wal_database_lock(&db_path);
28129
28130        cmd_status(
28131            dir.path(),
28132            StatusCommandOptions {
28133                fix: false,
28134                no_fix: false,
28135                json_output: true,
28136                compact: false,
28137                pretty: false,
28138                terse: false,
28139                schema: false,
28140            },
28141        )
28142        .unwrap();
28143
28144        let report = status::check_status(dir.path()).unwrap();
28145        assert!(matches!(
28146            report.index,
28147            status::IndexStatus::Fresh {
28148                recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
28149                ..
28150            }
28151        ));
28152        let locks = status::check_locks(dir.path(), None, None).unwrap();
28153        assert!(matches!(
28154            locks.writer_lock,
28155            status::WriterLockStatus::Absent { .. }
28156        ));
28157        assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
28158        assert!(
28159            locks
28160                .recommended_action
28161                .contains("wedged writer holding live WAL sidecars")
28162        );
28163    }
28164
28165    #[test]
28166    fn locks_report_uses_ancestor_project_root_for_nested_paths() {
28167        let dir = setup_graph_index();
28168        let nested = dir.path().join("src/nested");
28169        std::fs::create_dir_all(&nested).unwrap();
28170
28171        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28172        let report = status::check_locks(&root, Some(&nested), None).unwrap();
28173
28174        assert_eq!(report.source_root, dir.path());
28175        assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
28176    }
28177
28178    #[test]
28179    fn workspace_locks_report_infers_scope_from_nested_path() {
28180        let dir = setup_workspace();
28181        cmd_index(
28182            dir.path(),
28183            false,
28184            false,
28185            false,
28186            false,
28187            false,
28188            true,
28189            None,
28190            false,
28191            false,
28192            false,
28193            false,
28194            false,
28195            false,
28196        )
28197        .unwrap();
28198        let nested = dir.path().join("src/alpha/nested");
28199        std::fs::create_dir_all(&nested).unwrap();
28200
28201        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28202        let report = status::check_locks(&root, Some(&nested), None).unwrap();
28203        let cfg = config::Config::load(dir.path()).unwrap();
28204
28205        assert_eq!(report.label, "submodule `alpha` index");
28206        assert_eq!(report.source_root, dir.path().join("src/alpha"));
28207        assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
28208        assert_eq!(
28209            report.reindex_command,
28210            format!("tsift index --submodule alpha {}", dir.path().display())
28211        );
28212    }
28213
28214    #[test]
28215    fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
28216        let dir = setup_workspace();
28217        cmd_index(
28218            dir.path(),
28219            false,
28220            false,
28221            false,
28222            false,
28223            false,
28224            true,
28225            None,
28226            false,
28227            false,
28228            false,
28229            false,
28230            false,
28231            false,
28232        )
28233        .unwrap();
28234
28235        let alpha = dir.path().join("src/alpha/lib.rs");
28236        std::thread::sleep(std::time::Duration::from_millis(50));
28237        std::fs::write(
28238            &alpha,
28239            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28240        )
28241        .unwrap();
28242
28243        let result = cmd_search(
28244            "alpha_helper".to_string(),
28245            Some(dir.path().to_path_buf()),
28246            5,
28247            Some("lexical".to_string()),
28248            Some("alpha".to_string()),
28249            false,
28250            false,
28251            true,
28252            0,
28253            false,
28254            false,
28255            false,
28256            false,
28257            false,
28258            false,
28259            false,
28260        );
28261
28262        assert!(result.is_ok());
28263
28264        let cfg = config::Config::load(dir.path()).unwrap();
28265        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28266        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28267        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28268    }
28269
28270    #[test]
28271    fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28272        let dir = setup_workspace();
28273        cmd_index(
28274            dir.path(),
28275            false,
28276            false,
28277            false,
28278            false,
28279            false,
28280            true,
28281            None,
28282            false,
28283            false,
28284            false,
28285            false,
28286            false,
28287            false,
28288        )
28289        .unwrap();
28290
28291        let alpha = dir.path().join("src/alpha/lib.rs");
28292        std::thread::sleep(std::time::Duration::from_millis(50));
28293        std::fs::write(
28294            &alpha,
28295            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28296        )
28297        .unwrap();
28298
28299        let cfg = config::Config::load(dir.path()).unwrap();
28300        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28301
28302        let err = cmd_search(
28303            "alpha_helper".to_string(),
28304            Some(dir.path().to_path_buf()),
28305            5,
28306            Some("lexical".to_string()),
28307            Some("alpha".to_string()),
28308            false,
28309            false,
28310            false,
28311            0,
28312            false,
28313            false,
28314            false,
28315            false,
28316            false,
28317            false,
28318            false,
28319        )
28320        .unwrap_err();
28321
28322        assert!(err.to_string().contains("search aborted"));
28323        assert!(err.to_string().contains("submodule `alpha` index"));
28324        assert!(!err.to_string().contains("database is locked"));
28325    }
28326
28327    #[test]
28328    fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
28329        let dir = setup_workspace();
28330        cmd_index(
28331            dir.path(),
28332            false,
28333            false,
28334            false,
28335            false,
28336            false,
28337            true,
28338            None,
28339            false,
28340            false,
28341            false,
28342            false,
28343            false,
28344            false,
28345        )
28346        .unwrap();
28347
28348        let alpha = dir.path().join("src/alpha/lib.rs");
28349        std::thread::sleep(std::time::Duration::from_millis(50));
28350        std::fs::write(
28351            &alpha,
28352            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28353        )
28354        .unwrap();
28355
28356        let result = cmd_search(
28357            "alpha_helper".to_string(),
28358            Some(dir.path().to_path_buf()),
28359            5,
28360            Some("lexical".to_string()),
28361            None,
28362            true,
28363            false,
28364            true,
28365            0,
28366            false,
28367            false,
28368            false,
28369            false,
28370            false,
28371            false,
28372            false,
28373        );
28374
28375        assert!(result.is_ok());
28376
28377        let cfg = config::Config::load(dir.path()).unwrap();
28378        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28379        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28380        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28381    }
28382
28383    #[test]
28384    fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28385        let dir = setup_workspace();
28386        cmd_index(
28387            dir.path(),
28388            false,
28389            false,
28390            false,
28391            false,
28392            false,
28393            true,
28394            None,
28395            false,
28396            false,
28397            false,
28398            false,
28399            false,
28400            false,
28401        )
28402        .unwrap();
28403
28404        let alpha = dir.path().join("src/alpha/lib.rs");
28405        std::thread::sleep(std::time::Duration::from_millis(50));
28406        std::fs::write(
28407            &alpha,
28408            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28409        )
28410        .unwrap();
28411
28412        let cfg = config::Config::load(dir.path()).unwrap();
28413        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28414
28415        let err = cmd_search(
28416            "alpha_helper".to_string(),
28417            Some(dir.path().to_path_buf()),
28418            5,
28419            Some("lexical".to_string()),
28420            None,
28421            true,
28422            false,
28423            false,
28424            30,
28425            false,
28426            false,
28427            false,
28428            false,
28429            false,
28430            false,
28431            false,
28432        )
28433        .unwrap_err();
28434
28435        assert!(err.to_string().contains("stale"));
28436        assert!(err.to_string().contains("submodule `alpha` index"));
28437        assert!(!err.to_string().contains("database is locked"));
28438    }
28439
28440    #[test]
28441    fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
28442        let dir = setup_workspace();
28443        cmd_index(
28444            dir.path(),
28445            false,
28446            false,
28447            false,
28448            false,
28449            false,
28450            true,
28451            None,
28452            false,
28453            false,
28454            false,
28455            false,
28456            false,
28457            false,
28458        )
28459        .unwrap();
28460
28461        let err = cmd_search(
28462            "alpha_helper".to_string(),
28463            Some(dir.path().to_path_buf()),
28464            5,
28465            Some("lexical".to_string()),
28466            None,
28467            false,
28468            false,
28469            true,
28470            0,
28471            false,
28472            false,
28473            false,
28474            false,
28475            false,
28476            false,
28477            false,
28478        )
28479        .unwrap_err();
28480
28481        assert_workspace_search_requires_explicit_target(err);
28482        assert!(!dir.path().join(".tsift/index.db").exists());
28483    }
28484
28485    #[test]
28486    fn workspace_search_cmd_infers_scope_from_nested_path() {
28487        let dir = setup_workspace();
28488        cmd_index(
28489            dir.path(),
28490            false,
28491            false,
28492            false,
28493            false,
28494            false,
28495            true,
28496            None,
28497            false,
28498            false,
28499            false,
28500            false,
28501            false,
28502            false,
28503        )
28504        .unwrap();
28505        let nested = dir.path().join("src/alpha/nested");
28506        std::fs::create_dir_all(&nested).unwrap();
28507
28508        let result = cmd_search(
28509            "alpha_helper".to_string(),
28510            Some(nested),
28511            5,
28512            Some("lexical".to_string()),
28513            None,
28514            false,
28515            false,
28516            false,
28517            0,
28518            false,
28519            false,
28520            false,
28521            false,
28522            false,
28523            false,
28524            false,
28525        );
28526
28527        assert!(result.is_ok());
28528    }
28529
28530    #[test]
28531    fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
28532        let dir = setup_workspace_with_duplicate_leaf_names();
28533        cmd_index(
28534            dir.path(),
28535            false,
28536            false,
28537            false,
28538            false,
28539            false,
28540            true,
28541            None,
28542            false,
28543            false,
28544            false,
28545            false,
28546            false,
28547            false,
28548        )
28549        .unwrap();
28550        let nested = dir.path().join("vendor/foo/nested");
28551        std::fs::create_dir_all(&nested).unwrap();
28552
28553        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28554        let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
28555        let cfg = config::Config::load(dir.path()).unwrap();
28556
28557        assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
28558    }
28559
28560    #[test]
28561    fn graph_cmd_succeeds_while_writer_lock_is_held() {
28562        let dir = setup_graph_index();
28563        let db_path = dir.path().join(".tsift/index.db");
28564        let _lock = hold_write_lock(&db_path);
28565
28566        let result = cmd_graph(
28567            "main",
28568            dir.path(),
28569            false,
28570            false,
28571            None,
28572            20,
28573            false,
28574            true,
28575            false,
28576            false,
28577            false,
28578            false,
28579            false,
28580            TagpathSearchOpts::default(),
28581        );
28582
28583        assert!(result.is_ok());
28584    }
28585
28586    #[test]
28587    fn graph_cmd_autoindexes_stale_index_by_default() {
28588        let dir = setup_graph_index();
28589        std::thread::sleep(std::time::Duration::from_millis(50));
28590        std::fs::write(
28591            dir.path().join("main.rs"),
28592            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28593        )
28594        .unwrap();
28595
28596        let result = cmd_graph(
28597            "helper",
28598            dir.path(),
28599            true,
28600            false,
28601            None,
28602            20,
28603            false,
28604            true,
28605            false,
28606            false,
28607            false,
28608            false,
28609            false,
28610            TagpathSearchOpts::default(),
28611        );
28612
28613        assert!(result.is_ok());
28614        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
28615        let summary = db.compute_changes(dir.path()).unwrap();
28616        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28617    }
28618
28619    #[test]
28620    fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28621        let dir = setup_graph_index();
28622        let db_path = dir.path().join(".tsift/index.db");
28623        let _lock = hold_rollback_journal_lock(&db_path);
28624
28625        let result = cmd_graph(
28626            "main",
28627            dir.path(),
28628            false,
28629            false,
28630            None,
28631            20,
28632            false,
28633            true,
28634            false,
28635            false,
28636            false,
28637            false,
28638            false,
28639            TagpathSearchOpts::default(),
28640        );
28641
28642        assert!(result.is_ok());
28643    }
28644
28645    #[test]
28646    fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
28647        let dir = setup_graph_index();
28648        let nested = dir.path().join("src/nested");
28649        std::fs::create_dir_all(&nested).unwrap();
28650
28651        let result = cmd_graph(
28652            "helper",
28653            &nested,
28654            true,
28655            false,
28656            None,
28657            20,
28658            false,
28659            false,
28660            false,
28661            false,
28662            false,
28663            false,
28664            false,
28665            TagpathSearchOpts::default(),
28666        );
28667
28668        assert!(result.is_ok());
28669    }
28670
28671    #[test]
28672    fn communities_cmd_succeeds_while_writer_lock_is_held() {
28673        let dir = setup_graph_index();
28674        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
28675
28676        let result = cmd_communities(
28677            dir.path(),
28678            None,
28679            1,
28680            10,
28681            false,
28682            false,
28683            false,
28684            false,
28685            false,
28686            false,
28687            TagpathSearchOpts::default(),
28688        );
28689
28690        assert!(result.is_ok());
28691    }
28692
28693    #[test]
28694    fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28695        let dir = setup_graph_index();
28696        let db_path = dir.path().join(".tsift/index.db");
28697        let _lock = hold_rollback_journal_lock(&db_path);
28698
28699        let result = cmd_communities(
28700            dir.path(),
28701            None,
28702            1,
28703            10,
28704            false,
28705            false,
28706            false,
28707            false,
28708            false,
28709            false,
28710            TagpathSearchOpts::default(),
28711        );
28712
28713        assert!(result.is_ok());
28714    }
28715
28716    #[test]
28717    fn lint_finds_entities_from_project_root_index_db() {
28718        let dir = tempfile::tempdir().unwrap();
28719        std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
28720        std::fs::write(
28721            dir.path().join("README.md"),
28722            "alpha_helper should be backticked.\n",
28723        )
28724        .unwrap();
28725        cmd_index(
28726            dir.path(),
28727            false,
28728            false,
28729            false,
28730            false,
28731            false,
28732            false,
28733            None,
28734            false,
28735            false,
28736            false,
28737            false,
28738            false,
28739            false,
28740        )
28741        .unwrap();
28742
28743        let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
28744            .unwrap()
28745            .unwrap();
28746        let entities = lint::collect_entities_from_index_path(&root).unwrap();
28747        let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
28748
28749        assert!(
28750            result
28751                .annotations
28752                .iter()
28753                .any(|ann| ann.text == "alpha_helper")
28754        );
28755    }
28756
28757    // --- search timeout ---
28758
28759    #[test]
28760    fn search_direct_runs_ok() {
28761        let dir = tempfile::tempdir().unwrap();
28762        let search_dir = dir.path().to_path_buf();
28763        let cache_dir = search_dir.join(".tsift/search-cache");
28764        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28765        let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical", None);
28766        assert!(result.is_ok(), "direct search should succeed");
28767        assert!(
28768            cache_dir.exists(),
28769            "search should create the configured cache dir"
28770        );
28771    }
28772
28773    #[test]
28774    fn search_timeout_zero_disables_timeout() {
28775        let dir = tempfile::tempdir().unwrap();
28776        let search_dir = dir.path().to_path_buf();
28777        let cache_dir = search_dir.join(".tsift/search-cache");
28778        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28779        let result =
28780            run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[], None);
28781        assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
28782        assert!(
28783            cache_dir.exists(),
28784            "timeout=0 should keep using the stable search cache dir"
28785        );
28786    }
28787
28788    #[test]
28789    fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
28790        let dir = tempfile::tempdir().unwrap();
28791        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
28792        cmd_index(
28793            dir.path(),
28794            false,
28795            false,
28796            false,
28797            false,
28798            false,
28799            false,
28800            None,
28801            false,
28802            false,
28803            false,
28804            false,
28805            false,
28806            false,
28807        )
28808        .unwrap();
28809        let db_path = dir.path().join(".tsift/index.db");
28810        std::fs::remove_file(&db_path).unwrap();
28811        let search_target = SearchIndexTarget {
28812            label: "index".to_string(),
28813            db_path,
28814            source_root: dir.path().to_path_buf(),
28815            scope_name: None,
28816            reindex_cmd: format!("tsift index {}", dir.path().display()),
28817        };
28818
28819        let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
28820
28821        assert!(message.contains("timed out after 1s"));
28822        assert!(message.contains("index is missing"));
28823        assert!(message.contains("Run `tsift index"));
28824        assert!(!message.contains("search root looks fresh"));
28825    }
28826
28827    #[test]
28828    fn search_worker_output_path_uses_json_suffix() {
28829        let path = next_search_worker_output_path();
28830        assert!(path.extension().is_some_and(|ext| ext == "json"));
28831    }
28832
28833    #[test]
28834    fn fts_search_flag_value_parses_falsy_escape_hatch() {
28835        // #015t Phase 4: FTS5 is the default; only falsy values force legacy.
28836        for falsy in ["0", "false", "FALSE", " no ", "Off"] {
28837            assert!(
28838                fts_flag_value_disabled(falsy),
28839                "{falsy:?} should force the legacy TokenIndex path"
28840            );
28841        }
28842        for keeps_default in ["", "1", "true", "yes", "on", "maybe"] {
28843            assert!(
28844                !fts_flag_value_disabled(keeps_default),
28845                "{keeps_default:?} should keep the FTS5 default"
28846            );
28847        }
28848    }
28849
28850    #[test]
28851    fn run_sift_search_defaults_to_fts_when_index_db_present() {
28852        // #015t Phase 4 cutover: with the flag unset and a root index.db present,
28853        // run_sift_search uses the FTS5 path by default (strategy "fts").
28854        let dir = tempfile::tempdir().unwrap();
28855        let root = dir.path();
28856        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28857        index::IndexDb::open(&root.join(".tsift/index.db"))
28858            .unwrap()
28859            .apply_changes(root)
28860            .unwrap();
28861        let cache_dir = root.join(".tsift/search-cache");
28862
28863        // Guard against an ambient escape-hatch env from a parallel test/shell.
28864        if fts_search_forced_off() {
28865            return;
28866        }
28867        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28868        assert_eq!(response.strategy, "fts");
28869        assert!(response.hits.iter().any(|h| h.path.ends_with("alpha.rs")));
28870    }
28871
28872    #[test]
28873    fn run_sift_search_falls_back_to_lexical_without_index_db() {
28874        // No root index.db (e.g. un-indexed root reaching here directly): the
28875        // legacy TokenIndex/lexical path still serves the query.
28876        let dir = tempfile::tempdir().unwrap();
28877        let root = dir.path();
28878        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28879        let cache_dir = root.join(".tsift/search-cache");
28880
28881        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28882        assert_eq!(response.strategy, "lexical");
28883    }
28884
28885    #[test]
28886    fn run_sift_search_honors_threaded_freshness_verdict() {
28887        // #015t Phase 4b: the caller's freshness verdict overrides the in-engine
28888        // inspect. Some(true) ⇒ FTS without re-walking; Some(false) ⇒ legacy path
28889        // even with a fresh index.db (the degraded-read-only live-results case).
28890        let dir = tempfile::tempdir().unwrap();
28891        let root = dir.path();
28892        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28893        index::IndexDb::open(&root.join(".tsift/index.db"))
28894            .unwrap()
28895            .apply_changes(root)
28896            .unwrap();
28897        let cache_dir = root.join(".tsift/search-cache");
28898
28899        if fts_search_forced_off() {
28900            return;
28901        }
28902        let fresh =
28903            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(true)).unwrap();
28904        assert_eq!(fresh.strategy, "fts");
28905
28906        let stale =
28907            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(false)).unwrap();
28908        assert_eq!(stale.strategy, "lexical");
28909    }
28910
28911    // --- index quiet mode ---
28912
28913    #[test]
28914    fn index_quiet_suppresses_file_list() {
28915        let dir = setup_graph_index();
28916        let result = cmd_index(
28917            dir.path(),
28918            false,
28919            true,
28920            false,
28921            false,
28922            true,
28923            false,
28924            None,
28925            false,
28926            false,
28927            false,
28928            false,
28929            false,
28930            false,
28931        );
28932        assert!(result.is_ok());
28933    }
28934
28935    #[test]
28936    fn index_exit_code_implies_quiet() {
28937        let dir = setup_graph_index();
28938        let result = cmd_index(
28939            dir.path(),
28940            false,
28941            true,
28942            false,
28943            false,
28944            false,
28945            false,
28946            None,
28947            false,
28948            false,
28949            false,
28950            false,
28951            false,
28952            false,
28953        );
28954        assert!(result.is_ok());
28955    }
28956
28957    #[test]
28958    fn index_quiet_json_omits_changes() {
28959        let dir = setup_graph_index();
28960        let result = cmd_index(
28961            dir.path(),
28962            false,
28963            true,
28964            false,
28965            false,
28966            true,
28967            false,
28968            None,
28969            true,
28970            false,
28971            false,
28972            false,
28973            false,
28974            false,
28975        );
28976        assert!(result.is_ok());
28977    }
28978
28979    #[test]
28980    fn cli_workflow_defaults_to_search_topic() {
28981        let cli = parse_cli(["tsift", "workflow"]);
28982        match cli.command {
28983            Some(Commands::Workflow { topic, json }) => {
28984                assert_eq!(topic, "search");
28985                assert!(!json);
28986            }
28987            _ => panic!("expected Workflow command"),
28988        }
28989    }
28990
28991    #[test]
28992    fn search_workflow_recipe_preserves_handles_across_expansions() {
28993        let recipe = workflow::search_workflow_recipe();
28994        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
28995        assert_eq!(
28996            step_names,
28997            vec![
28998                "exact-anchor",
28999                "semantic-search",
29000                "explain-symbol",
29001                "summarize-selection",
29002                "digest-expansion"
29003            ]
29004        );
29005        assert!(
29006            recipe
29007                .handle_contract
29008                .iter()
29009                .any(|item| item.contains("originating command"))
29010        );
29011        assert!(
29012            recipe.steps[1]
29013                .preserves
29014                .iter()
29015                .any(|item| item.contains("sfam-*"))
29016        );
29017        assert!(
29018            recipe.steps[2]
29019                .preserves
29020                .iter()
29021                .any(|item| item.contains("ecall-*"))
29022        );
29023        assert!(
29024            recipe.steps[4]
29025                .preserves
29026                .iter()
29027                .any(|item| item.contains("artifact handles"))
29028        );
29029    }
29030
29031    #[test]
29032    fn kg_workflow_recipe_covers_extract_to_evidence() {
29033        let recipe = workflow::kg_workflow_recipe();
29034        assert_eq!(recipe.topic, "kg");
29035        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29036        assert_eq!(
29037            step_names,
29038            vec!["smoke-check", "extract", "status", "refresh", "evidence"]
29039        );
29040        // evidence uses --symbol (not a positional) and has no --budget flag
29041        let evidence = recipe.steps.last().unwrap();
29042        assert!(evidence.command.contains("kg evidence --symbol"));
29043        assert!(!evidence.command.contains("--budget"));
29044        // extract is the write step; reads should not re-extract
29045        assert!(
29046            recipe
29047                .handle_contract
29048                .iter()
29049                .any(|item| item.contains("Extract once"))
29050        );
29051    }
29052
29053    // --- JSON compact vs pretty ---
29054
29055    #[test]
29056    fn to_json_compact_default() {
29057        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29058        let compact = to_json(&val, false, false).unwrap();
29059        assert!(!compact.contains('\n'));
29060        assert!(
29061            compact.contains("\"a\":1")
29062                || compact.contains("\"a\": 1")
29063                || compact.contains("\"a\":")
29064        );
29065    }
29066
29067    #[test]
29068    fn to_json_pretty_indents() {
29069        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29070        let pretty = to_json(&val, true, false).unwrap();
29071        assert!(pretty.contains('\n'));
29072        assert!(pretty.contains("  "));
29073    }
29074
29075    #[test]
29076    fn to_json_compact_is_shorter() {
29077        let val =
29078            serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
29079        let compact = to_json(&val, false, false).unwrap();
29080        let pretty = to_json(&val, true, false).unwrap();
29081        assert!(compact.len() < pretty.len());
29082    }
29083
29084    #[test]
29085    fn terse_renames_keys() {
29086        let val =
29087            serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
29088        let result = to_json(&val, false, true).unwrap();
29089        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29090        assert!(parsed["_s"].is_object());
29091        let d = &parsed["d"];
29092        assert_eq!(d["cf"], "a.rs");
29093        assert_eq!(d["cn"], "main");
29094        assert_eq!(d["csl"], 10);
29095    }
29096
29097    #[test]
29098    fn terse_schema_only_includes_used_keys() {
29099        let val = serde_json::json!({"name": "test", "score": 0.5});
29100        let result = to_json(&val, false, true).unwrap();
29101        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29102        let schema = parsed["_s"].as_object().unwrap();
29103        assert_eq!(schema["n"], "name");
29104        assert_eq!(schema["sc"], "score");
29105        assert!(!schema.contains_key("cf"));
29106    }
29107
29108    #[test]
29109    fn terse_nested_arrays() {
29110        let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
29111        let result = to_json(&val, false, true).unwrap();
29112        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29113        let d = &parsed["d"];
29114        assert_eq!(d["crs"][0]["cn"], "a");
29115        assert_eq!(d["crs"][0]["cf"], "b.rs");
29116    }
29117
29118    #[test]
29119    fn terse_preserves_unknown_keys() {
29120        let val = serde_json::json!({"custom_field": "value", "name": "test"});
29121        let result = to_json(&val, false, true).unwrap();
29122        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29123        let d = &parsed["d"];
29124        assert_eq!(d["custom_field"], "value");
29125        assert_eq!(d["n"], "test");
29126    }
29127
29128    // --- ultra-terse ---
29129
29130    #[test]
29131    fn ultra_terse_strips_properties_from_graph_nodes() {
29132        let val = serde_json::json!({
29133            "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
29134        });
29135        let result = to_json_schema(&val, false, true, true, false).unwrap();
29136        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29137        let node = &parsed["d"]["nodes"][0];
29138        assert_eq!(node["id"], "fn:main");
29139        assert_eq!(node["k"], "fn");
29140        assert_eq!(node["n"], "main");
29141        assert!(node.get("properties").is_none());
29142    }
29143
29144    #[test]
29145    fn ultra_terse_strips_properties_from_graph_edges() {
29146        let val = serde_json::json!({
29147            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
29148        });
29149        let result = to_json_schema(&val, false, true, true, false).unwrap();
29150        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29151        let edge = &parsed["d"]["edges"][0];
29152        assert_eq!(edge["from_id"], "a");
29153        assert_eq!(edge["to_id"], "b");
29154        assert_eq!(edge["k"], "c");
29155        assert!(edge.get("properties").is_none());
29156    }
29157
29158    #[test]
29159    fn ultra_terse_abbreviates_edge_kinds() {
29160        let val = serde_json::json!({
29161            "edges": [
29162                {"from_id": "a", "to_id": "b", "kind": "defines"},
29163                {"from_id": "a", "to_id": "c", "kind": "contains"},
29164                {"from_id": "a", "to_id": "d", "kind": "imports"},
29165                {"from_id": "a", "to_id": "e", "kind": "mentions"},
29166                {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
29167                {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
29168                {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
29169                {"from_id": "a", "to_id": "i", "kind": "uses"},
29170                {"from_id": "a", "to_id": "j", "kind": "parent"},
29171                {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
29172            ]
29173        });
29174        let result = to_json_schema(&val, false, true, true, false).unwrap();
29175        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29176        let edges = &parsed["d"]["edges"].as_array().unwrap();
29177        assert_eq!(edges[0]["k"], "d");
29178        assert_eq!(edges[1]["k"], "ct");
29179        assert_eq!(edges[2]["k"], "i");
29180        assert_eq!(edges[3]["k"], "m");
29181        assert_eq!(edges[4]["k"], "sr");
29182        assert_eq!(edges[5]["k"], "bt");
29183        assert_eq!(edges[6]["k"], "sctx");
29184        assert_eq!(edges[7]["k"], "u");
29185        assert_eq!(edges[8]["k"], "p");
29186        assert_eq!(edges[9]["k"], "unknown_edge");
29187    }
29188
29189    #[test]
29190    fn ultra_terse_strips_provenance_freshness_from_edges() {
29191        let val = serde_json::json!({
29192            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
29193        });
29194        let result = to_json_schema(&val, false, true, true, false).unwrap();
29195        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29196        let edge = &parsed["d"]["edges"][0];
29197        assert!(edge.get("provenance").is_none());
29198        assert!(edge.get("freshness").is_none());
29199        assert_eq!(edge["k"], "c");
29200    }
29201
29202    #[test]
29203    fn ultra_terse_truncates_snippets() {
29204        let long_snippet = "x".repeat(120);
29205        let val = serde_json::json!({"snippet": long_snippet});
29206        let result = to_json_schema(&val, false, true, true, false).unwrap();
29207        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29208        let snipped = parsed["d"]["sn"].as_str().unwrap();
29209        assert_eq!(snipped.len(), 80);
29210        assert!(snipped.ends_with("..."));
29211    }
29212
29213    #[test]
29214    fn ultra_terse_truncates_abbreviated_snippet_key() {
29215        let long_snippet = "y".repeat(100);
29216        let val = serde_json::json!({"snippet": long_snippet});
29217        let result = to_json_schema(&val, false, true, true, false).unwrap();
29218        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29219        let snipped = parsed["d"]["sn"].as_str().unwrap();
29220        assert_eq!(snipped.len(), 80);
29221        assert!(snipped.ends_with("..."));
29222    }
29223
29224    #[test]
29225    fn ultra_terse_compacts_coverage_snapshot() {
29226        let val = serde_json::json!({
29227            "mode": "incremental",
29228            "total_sector_count": 10,
29229            "dirty_sector_count": 2,
29230            "active_rebuild": Some("rebuild-1"),
29231            "completed_dirty_sector_count": 1,
29232            "mounted_sector_count": 8,
29233            "rebuilding_sector_count": 1,
29234            "resumed_sector_count": 3,
29235            "reused_sector_count": 5
29236        });
29237        let result = to_json_schema(&val, false, true, true, false).unwrap();
29238        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29239        let d = &parsed["d"];
29240        assert_eq!(d["mode"], "incremental");
29241        assert_eq!(d["total_sector_count"], 10);
29242        assert_eq!(d["dirty_sector_count"], 2);
29243        assert!(d.get("active_rebuild").is_none());
29244        assert!(d.get("completed_dirty_sector_count").is_none());
29245        assert!(d.get("mounted_sector_count").is_none());
29246        assert!(d.get("rebuilding_sector_count").is_none());
29247        assert!(d.get("resumed_sector_count").is_none());
29248        assert!(d.get("reused_sector_count").is_none());
29249    }
29250
29251    #[test]
29252    fn ultra_terse_short_snippet_unchanged() {
29253        let val = serde_json::json!({"snippet": "short text"});
29254        let result = to_json_schema(&val, false, true, true, false).unwrap();
29255        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29256        assert_eq!(parsed["d"]["sn"], "short text");
29257    }
29258
29259    #[test]
29260    fn ultra_terse_non_graph_object_properties_preserved() {
29261        let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
29262        let result = to_json_schema(&val, false, true, true, false).unwrap();
29263        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29264        assert!(parsed["d"]["config"]["properties"].is_object());
29265    }
29266
29267    // --- schema-then-values ---
29268
29269    #[test]
29270    fn schema_converts_homogeneous_arrays() {
29271        let val = serde_json::json!({"symbols": [
29272            {"name": "foo", "kind": "fn", "line": 10},
29273            {"name": "bar", "kind": "fn", "line": 20}
29274        ]});
29275        let result = to_json_schema(&val, false, false, false, true).unwrap();
29276        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29277        let syms = &parsed["symbols"];
29278        let columns = syms["_c"]
29279            .as_array()
29280            .unwrap()
29281            .iter()
29282            .map(|value| value.as_str().unwrap())
29283            .collect::<Vec<_>>();
29284        let row0 = syms["_r"][0].as_array().unwrap();
29285        let row1 = syms["_r"][1].as_array().unwrap();
29286        let name_index = columns.iter().position(|column| *column == "name").unwrap();
29287        let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
29288        let line_index = columns.iter().position(|column| *column == "line").unwrap();
29289        assert_eq!(row0[name_index], "foo");
29290        assert_eq!(row0[kind_index], "fn");
29291        assert_eq!(row0[line_index], 10);
29292        assert_eq!(row1[name_index], "bar");
29293        assert_eq!(row1[kind_index], "fn");
29294        assert_eq!(row1[line_index], 20);
29295    }
29296
29297    #[test]
29298    fn schema_skips_short_arrays() {
29299        let val = serde_json::json!({"items": [{"name": "only"}]});
29300        let result = to_json_schema(&val, false, false, false, true).unwrap();
29301        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29302        assert!(parsed["items"].is_array());
29303        assert_eq!(parsed["items"][0]["name"], "only");
29304    }
29305
29306    #[test]
29307    fn schema_skips_heterogeneous_arrays() {
29308        let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
29309        let result = to_json_schema(&val, false, false, false, true).unwrap();
29310        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29311        assert!(parsed["items"].is_array());
29312        assert_eq!(parsed["items"][0]["a"], 1);
29313    }
29314
29315    #[test]
29316    fn schema_with_terse_combines() {
29317        let val = serde_json::json!({"callers": [
29318            {"caller_name": "a", "caller_file": "x.rs"},
29319            {"caller_name": "b", "caller_file": "y.rs"}
29320        ]});
29321        let result = to_json_schema(&val, false, true, false, true).unwrap();
29322        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29323        assert!(parsed["_s"].is_object());
29324        let d = &parsed["d"];
29325        let crs = &d["crs"];
29326        assert!(crs["_c"].is_array());
29327        assert!(crs["_r"].is_array());
29328        let columns = crs["_c"]
29329            .as_array()
29330            .unwrap()
29331            .iter()
29332            .map(|value| value.as_str().unwrap())
29333            .collect::<Vec<_>>();
29334        let row = crs["_r"][0].as_array().unwrap();
29335        let name_index = columns.iter().position(|column| *column == "cn").unwrap();
29336        let file_index = columns.iter().position(|column| *column == "cf").unwrap();
29337        assert_eq!(row[name_index], "a");
29338        assert_eq!(row[file_index], "x.rs");
29339    }
29340
29341    #[test]
29342    fn schema_preserves_non_object_arrays() {
29343        let val = serde_json::json!({"tags": ["a", "b", "c"]});
29344        let result = to_json_schema(&val, false, false, false, true).unwrap();
29345        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29346        assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
29347    }
29348
29349    #[test]
29350    fn cli_accepts_global_schema_flag() {
29351        let cli = parse_cli(["tsift", "--schema", "search", "test"]);
29352        assert!(cli.schema);
29353        assert!(matches!(cli.command, Some(Commands::Search { .. })));
29354    }
29355
29356    #[test]
29357    fn cli_accepts_global_envelope_flag() {
29358        let cli = parse_cli([
29359            "tsift",
29360            "--envelope",
29361            "context-pack",
29362            "tasks/software/tsift.md",
29363        ]);
29364        assert!(cli.envelope);
29365        assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
29366    }
29367
29368    #[test]
29369    fn cli_accepts_locks_command() {
29370        let cli = parse_cli(["tsift", "locks"]);
29371        assert!(matches!(cli.command, Some(Commands::Locks { .. })));
29372    }
29373
29374    #[test]
29375    fn cli_parses_memory_budget_guard_command() {
29376        let cli = parse_cli([
29377            "tsift",
29378            "memory",
29379            "budget-guard",
29380            "--file",
29381            "tool.log",
29382            "--budget-tokens",
29383            "1000",
29384            "--json",
29385        ]);
29386        match cli.command {
29387            Some(Commands::Memory {
29388                command:
29389                    crate::cli::MemoryCommand::BudgetGuard {
29390                        file,
29391                        budget_tokens,
29392                        json,
29393                        ..
29394                    },
29395            }) => {
29396                assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
29397                assert_eq!(budget_tokens, 1000);
29398                assert!(json);
29399            }
29400            _ => panic!("expected memory budget-guard command"),
29401        }
29402    }
29403
29404    #[test]
29405    fn cli_parses_memory_capture_agent_doc_closeout_command() {
29406        let cli = parse_cli([
29407            "tsift",
29408            "memory",
29409            "capture-agent-doc-closeout",
29410            ".",
29411            "--session-path",
29412            "tasks/software/tsift.md",
29413            "--prompt-target",
29414            "do [#tsiftmemhooks]",
29415            "--response-summary",
29416            "wired closeout capture",
29417            "--commit-hash",
29418            "abc123",
29419            "--session-check-status",
29420            "clean",
29421            "--json",
29422        ]);
29423        match cli.command {
29424            Some(Commands::Memory {
29425                command:
29426                    crate::cli::MemoryCommand::CaptureAgentDocCloseout {
29427                        path,
29428                        session_path,
29429                        prompt_target,
29430                        response_summary,
29431                        commit_hash,
29432                        session_check_status,
29433                        json,
29434                    },
29435            }) => {
29436                assert_eq!(path, std::path::PathBuf::from("."));
29437                assert_eq!(
29438                    session_path,
29439                    std::path::PathBuf::from("tasks/software/tsift.md")
29440                );
29441                assert_eq!(prompt_target, "do [#tsiftmemhooks]");
29442                assert_eq!(response_summary, "wired closeout capture");
29443                assert_eq!(commit_hash.as_deref(), Some("abc123"));
29444                assert_eq!(session_check_status, "clean");
29445                assert!(json);
29446            }
29447            _ => panic!("expected memory capture-agent-doc-closeout command"),
29448        }
29449    }
29450
29451    #[test]
29452    fn cli_parses_memory_project_graph_read_policy() {
29453        let cli = parse_cli([
29454            "tsift",
29455            "memory",
29456            "project-graph",
29457            ".",
29458            "--read-policy",
29459            "query-relevant",
29460            "--query",
29461            "semantic memory",
29462            "--limit",
29463            "7",
29464            "--json",
29465        ]);
29466        match cli.command {
29467            Some(Commands::Memory {
29468                command:
29469                    crate::cli::MemoryCommand::ProjectGraph {
29470                        read_policy,
29471                        query,
29472                        limit,
29473                        json,
29474                        ..
29475                    },
29476            }) => {
29477                assert_eq!(
29478                    read_policy,
29479                    crate::cli::MemoryProjectReadPolicy::QueryRelevant
29480                );
29481                assert_eq!(query.as_deref(), Some("semantic memory"));
29482                assert_eq!(limit, 7);
29483                assert!(json);
29484            }
29485            _ => panic!("expected memory project-graph command"),
29486        }
29487    }
29488
29489    #[test]
29490    fn cli_locks_accepts_scope_flag() {
29491        let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
29492        match cli.command {
29493            Some(Commands::Locks { scope, .. }) => {
29494                assert_eq!(scope.as_deref(), Some("alpha"));
29495            }
29496            _ => panic!("expected Locks command"),
29497        }
29498    }
29499
29500    #[test]
29501    fn cli_search_accepts_autoindex_flag() {
29502        let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
29503        match cli.command {
29504            Some(Commands::Search {
29505                autoindex,
29506                no_autoindex,
29507                ..
29508            }) => {
29509                assert!(autoindex);
29510                assert!(!no_autoindex);
29511            }
29512            _ => panic!("expected Search command"),
29513        }
29514    }
29515
29516    #[test]
29517    fn cli_search_accepts_exact_flag() {
29518        let cli = parse_cli(["tsift", "search", "test", "--exact"]);
29519        match cli.command {
29520            Some(Commands::Search {
29521                exact, strategy, ..
29522            }) => {
29523                assert!(exact);
29524                assert!(strategy.is_none());
29525            }
29526            _ => panic!("expected Search command"),
29527        }
29528    }
29529
29530    #[test]
29531    fn cli_parses_diff_digest_command() {
29532        let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
29533        match cli.command {
29534            Some(Commands::DiffDigest {
29535                json,
29536                path,
29537                cached,
29538                revision,
29539                max_parsed_files,
29540            }) => {
29541                assert!(json);
29542                assert_eq!(path, PathBuf::from("."));
29543                assert!(!cached);
29544                assert!(revision.is_none());
29545                assert_eq!(max_parsed_files, 25);
29546            }
29547            _ => panic!("expected DiffDigest command"),
29548        }
29549    }
29550
29551    #[test]
29552    fn cli_rejects_conflicting_diff_digest_modes() {
29553        match try_parse_cli([
29554            "tsift",
29555            "diff-digest",
29556            "--cached",
29557            "--revision",
29558            "HEAD",
29559            ".",
29560        ]) {
29561            Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
29562            Err(err) => {
29563                assert!(err.to_string().contains("--cached"));
29564                assert!(err.to_string().contains("--revision"));
29565            }
29566        }
29567    }
29568
29569    #[test]
29570    fn cli_parses_test_digest_command() {
29571        let cli = parse_cli([
29572            "tsift",
29573            "test-digest",
29574            "--path",
29575            ".",
29576            "--input",
29577            "target/test.log",
29578            "--runner",
29579            "cargo",
29580            "--json",
29581        ]);
29582        match cli.command {
29583            Some(Commands::TestDigest {
29584                json,
29585                path,
29586                input,
29587                runner,
29588            }) => {
29589                assert!(json);
29590                assert_eq!(path, PathBuf::from("."));
29591                assert_eq!(input, Some(PathBuf::from("target/test.log")));
29592                assert_eq!(runner.as_deref(), Some("cargo"));
29593            }
29594            _ => panic!("expected TestDigest command"),
29595        }
29596    }
29597
29598    #[test]
29599    fn cli_parses_log_digest_command() {
29600        let cli = parse_cli([
29601            "tsift",
29602            "log-digest",
29603            "--path",
29604            ".",
29605            "--input",
29606            "target/build.log",
29607            "--json",
29608        ]);
29609        match cli.command {
29610            Some(Commands::LogDigest {
29611                json,
29612                path,
29613                input,
29614                fixture,
29615                fail_under,
29616            }) => {
29617                assert!(json);
29618                assert_eq!(path, PathBuf::from("."));
29619                assert_eq!(input, Some(PathBuf::from("target/build.log")));
29620                assert!(fixture.is_none());
29621                assert!(!fail_under);
29622            }
29623            _ => panic!("expected LogDigest command"),
29624        }
29625    }
29626
29627    #[test]
29628    fn cli_parses_metric_digest_command() {
29629        let cli = parse_cli([
29630            "tsift",
29631            "metric-digest",
29632            "--input",
29633            "target/runs.json",
29634            "--baseline",
29635            "target/prior.json",
29636            "--metric",
29637            "session_mae",
29638            "--lower-is-better",
29639            "session_mae",
29640            "--history",
29641            "4",
29642            "--top",
29643            "2",
29644            "--json",
29645        ]);
29646        match cli.command {
29647            Some(Commands::MetricDigest {
29648                input,
29649                baseline,
29650                metrics,
29651                lower_is_better,
29652                history,
29653                top,
29654                json,
29655                ..
29656            }) => {
29657                assert!(json);
29658                assert_eq!(input, Some(PathBuf::from("target/runs.json")));
29659                assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
29660                assert_eq!(metrics, vec!["session_mae"]);
29661                assert_eq!(lower_is_better, vec!["session_mae"]);
29662                assert_eq!(history, 4);
29663                assert_eq!(top, 2);
29664            }
29665            _ => panic!("expected MetricDigest command"),
29666        }
29667    }
29668
29669    #[test]
29670    fn cli_parses_dci_benchmark_command() {
29671        let cli = parse_cli([
29672            "tsift",
29673            "dci-benchmark",
29674            "--fixture",
29675            "fixtures/dci-search-benchmark.json",
29676            "--json",
29677        ]);
29678        match cli.command {
29679            Some(Commands::DciBenchmark { fixture, json }) => {
29680                assert!(json);
29681                assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
29682            }
29683            _ => panic!("expected DciBenchmark command"),
29684        }
29685    }
29686
29687    #[test]
29688    fn cli_parses_session_digest_command() {
29689        let cli = parse_cli([
29690            "tsift",
29691            "session-digest",
29692            "--path",
29693            ".",
29694            "--input",
29695            "target/session.md",
29696            "--source",
29697            "markdown",
29698            "--json",
29699        ]);
29700        match cli.command {
29701            Some(Commands::SessionDigest {
29702                json,
29703                path,
29704                input,
29705                source,
29706            }) => {
29707                assert!(json);
29708                assert_eq!(path, PathBuf::from("."));
29709                assert_eq!(input, Some(PathBuf::from("target/session.md")));
29710                assert_eq!(source.as_deref(), Some("markdown"));
29711            }
29712            _ => panic!("expected SessionDigest command"),
29713        }
29714    }
29715
29716    #[test]
29717    fn cli_parses_session_cost_command() {
29718        let cli = parse_cli([
29719            "tsift",
29720            "session-cost",
29721            "--input",
29722            "target/session.jsonl",
29723            "--source",
29724            "codex-jsonl",
29725            "--json",
29726        ]);
29727        match cli.command {
29728            Some(Commands::SessionCost {
29729                json,
29730                input,
29731                fixture,
29732                fail_under,
29733                source,
29734            }) => {
29735                assert!(json);
29736                assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
29737                assert_eq!(fixture, None);
29738                assert!(!fail_under);
29739                assert_eq!(source.as_deref(), Some("codex-jsonl"));
29740            }
29741            _ => panic!("expected SessionCost command"),
29742        }
29743
29744        let cli = parse_cli([
29745            "tsift",
29746            "session-cost",
29747            "--fixture",
29748            "fixtures/real-session-prompt-cache-effectiveness.json",
29749            "--fail-under",
29750            "--json",
29751        ]);
29752        match cli.command {
29753            Some(Commands::SessionCost {
29754                json,
29755                input,
29756                fixture,
29757                fail_under,
29758                source,
29759            }) => {
29760                assert!(json);
29761                assert_eq!(input, None);
29762                assert_eq!(
29763                    fixture,
29764                    Some(PathBuf::from(
29765                        "fixtures/real-session-prompt-cache-effectiveness.json"
29766                    ))
29767                );
29768                assert!(fail_under);
29769                assert_eq!(source, None);
29770            }
29771            _ => panic!("expected SessionCost command"),
29772        }
29773    }
29774
29775    #[test]
29776    fn cli_parses_session_review_command() {
29777        let cli = parse_cli([
29778            "tsift",
29779            "session-review",
29780            "tasks/software/tsift.md",
29781            "--next-context",
29782            "--json",
29783        ]);
29784        match cli.command {
29785            Some(Commands::SessionReview {
29786                json,
29787                next_context,
29788                path,
29789                ..
29790            }) => {
29791                assert!(json);
29792                assert!(next_context);
29793                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29794            }
29795            _ => panic!("expected SessionReview command"),
29796        }
29797    }
29798
29799    #[test]
29800    fn cli_search_accepts_budget_flags() {
29801        let cli = parse_cli([
29802            "tsift",
29803            "search",
29804            "alpha_helper",
29805            "--max-items",
29806            "3",
29807            "--max-bytes",
29808            "96",
29809        ]);
29810        match cli.command {
29811            Some(Commands::Search {
29812                max_items,
29813                max_bytes,
29814                ..
29815            }) => {
29816                assert_eq!(max_items, Some(3));
29817                assert_eq!(max_bytes, Some(96));
29818            }
29819            _ => panic!("expected Search command"),
29820        }
29821    }
29822
29823    #[test]
29824    fn cli_search_accepts_budget_preset() {
29825        let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
29826        match cli.command {
29827            Some(Commands::Search { budget, .. }) => {
29828                assert_eq!(budget, Some(ResponseBudgetPreset::Small));
29829            }
29830            _ => panic!("expected Search command"),
29831        }
29832    }
29833
29834    #[test]
29835    fn cli_search_accepts_ast_facet_filters() {
29836        let cli = parse_cli([
29837            "tsift",
29838            "search",
29839            "setup",
29840            "--lang",
29841            "markdown",
29842            "--kind",
29843            "list_item",
29844            "--node-kind",
29845            "list_item",
29846            "--section",
29847            "Install",
29848            "--parent",
29849            "Run setup.",
29850            "--child",
29851            "Confirm setup.",
29852            "--fence-language",
29853            "rust",
29854            "--list-depth",
29855            "1",
29856            "--heading-level",
29857            "2",
29858        ]);
29859        match cli.command {
29860            Some(Commands::Search {
29861                lang,
29862                kind,
29863                node_kind,
29864                section,
29865                parent,
29866                child,
29867                fence_language,
29868                list_depth,
29869                heading_level,
29870                ..
29871            }) => {
29872                assert_eq!(lang, vec!["markdown"]);
29873                assert_eq!(kind, vec!["list_item"]);
29874                assert_eq!(node_kind, vec!["list_item"]);
29875                assert_eq!(section, vec!["Install"]);
29876                assert_eq!(parent, vec!["Run setup."]);
29877                assert_eq!(child, vec!["Confirm setup."]);
29878                assert_eq!(fence_language, vec!["rust"]);
29879                assert_eq!(list_depth, vec![1]);
29880                assert_eq!(heading_level, vec![2]);
29881            }
29882            _ => panic!("expected Search command"),
29883        }
29884    }
29885
29886    #[test]
29887    fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
29888        let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
29889        assert_eq!(small.preview_items(), 3);
29890        assert_eq!(small.preview_bytes(), 120);
29891        assert_eq!(small.follow_up_items(), 4);
29892
29893        let overridden =
29894            ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
29895        assert_eq!(overridden.preview_items(), 7);
29896        assert_eq!(overridden.preview_bytes(), 120);
29897        assert_eq!(overridden.follow_up_items(), 7);
29898
29899        let envelope_default = ResponseBudget::from_cli(None, None, None, true);
29900        assert!(envelope_default.is_active());
29901    }
29902
29903    #[test]
29904    fn cli_explain_accepts_budget_flags() {
29905        let cli = parse_cli([
29906            "tsift",
29907            "explain",
29908            "alpha_helper",
29909            "--max-items",
29910            "2",
29911            "--max-bytes",
29912            "80",
29913        ]);
29914        match cli.command {
29915            Some(Commands::Explain {
29916                max_items,
29917                max_bytes,
29918                ..
29919            }) => {
29920                assert_eq!(max_items, Some(2));
29921                assert_eq!(max_bytes, Some(80));
29922            }
29923            _ => panic!("expected Explain command"),
29924        }
29925    }
29926
29927    #[test]
29928    fn cli_session_review_accepts_budget_flags() {
29929        let cli = parse_cli([
29930            "tsift",
29931            "session-review",
29932            "tasks/software/tsift.md",
29933            "--max-items",
29934            "4",
29935            "--max-bytes",
29936            "120",
29937        ]);
29938        match cli.command {
29939            Some(Commands::SessionReview {
29940                max_items,
29941                max_bytes,
29942                ..
29943            }) => {
29944                assert_eq!(max_items, Some(4));
29945                assert_eq!(max_bytes, Some(120));
29946            }
29947            _ => panic!("expected SessionReview command"),
29948        }
29949    }
29950
29951    #[test]
29952    fn cli_parses_context_pack_command() {
29953        let cli = parse_cli([
29954            "tsift",
29955            "context-pack",
29956            "tasks/software/tsift.md",
29957            "--test-input",
29958            "target/test.log",
29959            "--runner",
29960            "cargo",
29961            "--log-input",
29962            "target/build.log",
29963            "--max-items",
29964            "3",
29965            "--max-bytes",
29966            "96",
29967            "--json",
29968        ]);
29969        match cli.command {
29970            Some(Commands::ContextPack {
29971                path,
29972                test_input,
29973                runner,
29974                log_input,
29975                json,
29976                max_items,
29977                max_bytes,
29978                budget,
29979                convex_snapshot,
29980            }) => {
29981                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29982                assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
29983                assert_eq!(runner.as_deref(), Some("cargo"));
29984                assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
29985                assert!(json);
29986                assert_eq!(max_items, Some(3));
29987                assert_eq!(max_bytes, Some(96));
29988                assert!(budget.is_none());
29989                assert!(convex_snapshot.is_none());
29990            }
29991            _ => panic!("expected ContextPack command"),
29992        }
29993    }
29994
29995    #[test]
29996    fn cli_parses_token_savings_command() {
29997        let cli = parse_cli([
29998            "tsift",
29999            "token-savings",
30000            "--fixture",
30001            "fixtures/tsift-token-savings.json",
30002            "--fail-under",
30003            "--json",
30004        ]);
30005        match cli.command {
30006            Some(Commands::TokenSavings {
30007                fixture,
30008                fail_under,
30009                json,
30010            }) => {
30011                assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
30012                assert!(fail_under);
30013                assert!(json);
30014            }
30015            _ => panic!("expected TokenSavings command"),
30016        }
30017    }
30018
30019    #[test]
30020    fn token_savings_report_records_fixture_thresholds() {
30021        let raw_symbols = [
30022            "validate_user",
30023            "validateUser",
30024            "ValidateUser",
30025            "validate-user",
30026            "VALIDATE_USER",
30027            "Validate_User",
30028            "raw_symbol",
30029            "rawSymbol",
30030            "RawSymbol",
30031            "raw-symbol",
30032            "RAW_SYMBOL",
30033            "Raw_Symbol",
30034        ]
30035        .iter()
30036        .enumerate()
30037        .map(|(idx, identifier)| TokenSavingsRawSymbol {
30038            identifier: (*identifier).to_string(),
30039            file: format!("src/example_{idx}.rs"),
30040            line: (idx + 1) as u64,
30041            context: "function".to_string(),
30042        })
30043        .collect();
30044        let fixture = TokenSavingsFixture {
30045            schema_version: 1,
30046            description: "fixture".to_string(),
30047            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30048            cases: vec![TokenSavingsFixtureCase {
30049                name: "search-preview".to_string(),
30050                surface: "search".to_string(),
30051                minimum_savings_percent: 40.0,
30052                raw_symbols,
30053                tagpath_families: vec![
30054                    TokenSavingsFamily {
30055                        canonical: "validate_user".to_string(),
30056                        count: 6,
30057                        aliases: BTreeMap::new(),
30058                    },
30059                    TokenSavingsFamily {
30060                        canonical: "raw_symbol".to_string(),
30061                        count: 6,
30062                        aliases: BTreeMap::new(),
30063                    },
30064                ],
30065                context_pack_inputs: None,
30066                session_review_inputs: None,
30067                source_read_inputs: None,
30068                markdown_projection_inputs: None,
30069            }],
30070        };
30071
30072        let report = build_token_savings_report(&fixture).unwrap();
30073
30074        assert!(report.pass);
30075        assert_eq!(report.cases[0].raw_symbol_count, 12);
30076        assert_eq!(report.cases[0].family_count, 2);
30077        assert_eq!(report.cases[0].status, "pass");
30078        assert!(report.cases[0].byte_delta > 0);
30079        assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
30080        assert!(report.cases[0].savings_percent >= 40.0);
30081    }
30082
30083    #[test]
30084    fn token_savings_source_read_inputs_preserve_required_anchors() {
30085        let fixture = TokenSavingsFixture {
30086            schema_version: 1,
30087            description: "fixture".to_string(),
30088            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30089            cases: vec![TokenSavingsFixtureCase {
30090                name: "source-read".to_string(),
30091                surface: "source-read".to_string(),
30092                minimum_savings_percent: 40.0,
30093                raw_symbols: Vec::new(),
30094                tagpath_families: Vec::new(),
30095                context_pack_inputs: None,
30096                session_review_inputs: None,
30097                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30098                    reads: vec![TokenSavingsSourceReadInput {
30099                        command: "sed -n '40,160p' src/main.rs".to_string(),
30100                        file: "src/main.rs".to_string(),
30101                        raw_start: 40,
30102                        raw_lines: 121,
30103                        raw_excerpt: "line 40\n".repeat(121),
30104                        envelope_start: 40,
30105                        envelope_lines: 121,
30106                        required_line_anchors: vec![40, 120, 160],
30107                    }],
30108                }),
30109                markdown_projection_inputs: None,
30110            }],
30111        };
30112
30113        let report = build_token_savings_report(&fixture).unwrap();
30114
30115        assert!(report.pass);
30116        assert_eq!(report.cases[0].surface, "source-read");
30117        assert!(report.cases[0].savings_percent >= 40.0);
30118    }
30119
30120    #[test]
30121    fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
30122        let fixture = TokenSavingsFixture {
30123            schema_version: 1,
30124            description: "fixture".to_string(),
30125            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30126            cases: vec![TokenSavingsFixtureCase {
30127                name: "source-read".to_string(),
30128                surface: "source-read".to_string(),
30129                minimum_savings_percent: 40.0,
30130                raw_symbols: Vec::new(),
30131                tagpath_families: Vec::new(),
30132                context_pack_inputs: None,
30133                session_review_inputs: None,
30134                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30135                    reads: vec![TokenSavingsSourceReadInput {
30136                        command: "cat src/main.rs".to_string(),
30137                        file: "src/main.rs".to_string(),
30138                        raw_start: 1,
30139                        raw_lines: 200,
30140                        raw_excerpt: "line\n".repeat(200),
30141                        envelope_start: 1,
30142                        envelope_lines: 80,
30143                        required_line_anchors: vec![120],
30144                    }],
30145                }),
30146                markdown_projection_inputs: None,
30147            }],
30148        };
30149
30150        let err = match build_token_savings_report(&fixture) {
30151            Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
30152            Err(err) => err,
30153        };
30154
30155        assert!(err.to_string().contains("hides required line anchor 120"));
30156    }
30157
30158    #[test]
30159    fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
30160        let fixture = TokenSavingsFixture {
30161            schema_version: 1,
30162            description: "fixture".to_string(),
30163            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30164            cases: vec![TokenSavingsFixtureCase {
30165                name: "markdown-projection".to_string(),
30166                surface: "context-pack".to_string(),
30167                minimum_savings_percent: 40.0,
30168                raw_symbols: Vec::new(),
30169                tagpath_families: Vec::new(),
30170                context_pack_inputs: None,
30171                session_review_inputs: None,
30172                source_read_inputs: None,
30173                markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
30174                    documents: vec![TokenSavingsMarkdownProjectionInput {
30175                        command: "context-pack markdown body".to_string(),
30176                        file: "tasks/software/tsift.md".to_string(),
30177                        raw_markdown: "# Heading\n\n".repeat(120),
30178                        outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
30179                        selected_nodes: vec!["mdast-selected".to_string()],
30180                        expand:
30181                            "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
30182                                .to_string(),
30183                    }],
30184                }),
30185            }],
30186        };
30187
30188        let report = build_token_savings_report(&fixture).unwrap();
30189
30190        assert!(report.pass);
30191        assert_eq!(report.cases[0].surface, "context-pack");
30192        assert!(report.cases[0].savings_percent >= 40.0);
30193    }
30194
30195    #[test]
30196    fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
30197        let mut content = String::from("# Cache Root\n\n");
30198        for idx in 0..96 {
30199            content.push_str(&format!(
30200                "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
30201            ));
30202        }
30203
30204        let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30205        assert!(!first.cache_hit);
30206        assert!(first.nodes.len() > 200);
30207
30208        let sections = markdown_section_spans(&content).unwrap();
30209        let list_items = markdown_block_spans(&content, "list_item").unwrap();
30210        let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
30211        let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30212
30213        assert!(second.cache_hit);
30214        assert_eq!(second.nodes.len(), first.nodes.len());
30215        assert_eq!(sections.len(), 97);
30216        assert_eq!(list_items.len(), 96);
30217        assert_eq!(code_blocks.len(), 96);
30218        let first_code = first
30219            .nodes
30220            .iter()
30221            .find(|node| node.kind == "code_block")
30222            .expect("expected a Markdown code block");
30223        let first_code_node = markdown_ast_node(
30224            Path::new("/repo"),
30225            "semantic-edit",
30226            first_code,
30227            content.as_bytes(),
30228            &first.nodes,
30229            8,
30230        );
30231        assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
30232        assert_eq!(
30233            first_code_node.metadata.embedded_symbols[0].name,
30234            "sample_0"
30235        );
30236        assert_eq!(
30237            first_code_node.metadata.embedded_symbols[0].language,
30238            "rust"
30239        );
30240    }
30241
30242    #[test]
30243    fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
30244        let response = empty_search_response(Path::new("/repo"), "lexical");
30245        let symbol_hits = vec![index::SymbolHit {
30246            name: "alpha_helper_with_a_long_name".to_string(),
30247            kind: "function".to_string(),
30248            language: "rust".to_string(),
30249            file: "/repo/src/lib.rs".to_string(),
30250            line: 12,
30251            end_line: None,
30252            node_kind: None,
30253            start_byte: None,
30254            end_byte: None,
30255            body_start_byte: None,
30256            body_end_byte: None,
30257            tags: None,
30258            score: 0.98,
30259            match_type: "exact_name".to_string(),
30260            tagpath_handle: None,
30261        }];
30262
30263        let report = build_relative_search_budget_report(
30264            "alpha_helper_with_a_long_name",
30265            "lexical",
30266            Path::new("/repo"),
30267            &response,
30268            &symbol_hits,
30269            ResponseBudget::new(Some(1), Some(12)),
30270            &SearchFacetFilters::default(),
30271        );
30272
30273        assert_eq!(report.symbols.len(), 1);
30274        assert!(report.symbols[0].handle.starts_with("sfam-"));
30275        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
30276        assert_eq!(report.symbols[0].name, "alpha_hel...");
30277        assert_eq!(report.symbols[0].file, "src/lib.rs");
30278        assert!(report.symbols[0].expand.contains("tsift search"));
30279    }
30280
30281    #[test]
30282    fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
30283        let dir = tempfile::tempdir().unwrap();
30284        let src_dir = dir.path().join("src");
30285        fs::create_dir_all(&src_dir).unwrap();
30286        let source = "fn alpha_helper() {\n    beta();\n}\n";
30287        let file = src_dir.join("lib.rs");
30288        fs::write(&file, source).unwrap();
30289        let body_start = source.find("{\n").unwrap() + 1;
30290        let body_end = source.rfind("\n}").unwrap() + 1;
30291
30292        let response = empty_search_response(dir.path(), "lexical");
30293        let symbol_hits = vec![index::SymbolHit {
30294            name: "alpha_helper".to_string(),
30295            kind: "function".to_string(),
30296            language: "rust".to_string(),
30297            file: file.to_string_lossy().to_string(),
30298            line: 0,
30299            end_line: Some(2),
30300            node_kind: Some("function_item".to_string()),
30301            start_byte: Some(0),
30302            end_byte: Some(i64::try_from(source.len()).unwrap()),
30303            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30304            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30305            tags: Some("alpha,helper".to_string()),
30306            score: 0.98,
30307            match_type: "exact_name".to_string(),
30308            tagpath_handle: None,
30309        }];
30310
30311        let report = build_relative_search_budget_report(
30312            "alpha helper",
30313            "lexical",
30314            dir.path(),
30315            &response,
30316            &symbol_hits,
30317            ResponseBudget::new(Some(5), Some(96)),
30318            &SearchFacetFilters::default(),
30319        );
30320
30321        let symbol = &report.symbols[0];
30322        assert_eq!(symbol.language, "rust");
30323        assert_eq!(symbol.end_line, Some(2));
30324        let ast = symbol
30325            .ast
30326            .as_ref()
30327            .expect("search symbol preview should expose an AST span artifact");
30328        assert_eq!(ast.artifact_kind, "ast_span");
30329        assert!(ast.span.handle.starts_with("span-"));
30330        assert_eq!(ast.span.node_kind, "function_item");
30331        assert_eq!(ast.span.start_byte, 0);
30332        assert_eq!(ast.span.end_byte, source.len());
30333        assert_eq!(ast.span.body_start_byte, Some(body_start));
30334        assert_eq!(ast.span.body_end_byte, Some(body_end));
30335        assert!(ast.expand.source_window.contains("source-read"));
30336        assert!(
30337            ast.expand
30338                .source_body
30339                .as_ref()
30340                .unwrap()
30341                .contains("source-read")
30342        );
30343        assert!(ast.expand.symbol_read.contains("symbol-read"));
30344        assert!(ast.expand.markdown_ast.is_none());
30345    }
30346
30347    #[test]
30348    fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
30349        let dir = tempfile::tempdir().unwrap();
30350        let source = "# Guide\n\n## Install\n\n- Run setup.\n";
30351        let file = dir.path().join("README.md");
30352        fs::write(&file, source).unwrap();
30353        let heading_start = source.find("## Install").unwrap();
30354        let heading_end = source.len();
30355
30356        let response = empty_search_response(dir.path(), "lexical");
30357        let symbol_hits = vec![index::SymbolHit {
30358            name: "Install".to_string(),
30359            kind: "heading".to_string(),
30360            language: "markdown".to_string(),
30361            file: file.to_string_lossy().to_string(),
30362            line: 2,
30363            end_line: Some(4),
30364            node_kind: Some("atx_heading".to_string()),
30365            start_byte: Some(i64::try_from(heading_start).unwrap()),
30366            end_byte: Some(i64::try_from(heading_end).unwrap()),
30367            body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
30368            body_end_byte: Some(i64::try_from(heading_end).unwrap()),
30369            tags: Some("install".to_string()),
30370            score: 1.0,
30371            match_type: "exact_name".to_string(),
30372            tagpath_handle: None,
30373        }];
30374
30375        let report = build_relative_search_budget_report(
30376            "Install",
30377            "lexical",
30378            dir.path(),
30379            &response,
30380            &symbol_hits,
30381            ResponseBudget::new(Some(5), Some(96)),
30382            &SearchFacetFilters::default(),
30383        );
30384
30385        let ast = report.symbols[0]
30386            .ast
30387            .as_ref()
30388            .expect("Markdown search symbol should expose an AST span artifact");
30389        assert_eq!(ast.span.node_kind, "atx_heading");
30390        assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
30391        let markdown_ast = ast
30392            .expand
30393            .markdown_ast
30394            .as_ref()
30395            .expect("Markdown symbols should include markdown-ast expansion");
30396        assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
30397        assert!(markdown_ast.contains("--node"), "{markdown_ast}");
30398        assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
30399        assert!(ast.expand.source_window.contains("source-read"));
30400        assert!(ast.expand.symbol_read.contains("symbol-read"));
30401    }
30402
30403    #[test]
30404    fn search_budget_report_exposes_markdown_embedded_code_symbols() {
30405        let dir = tempfile::tempdir().unwrap();
30406        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30407        let file = dir.path().join("README.md");
30408        fs::write(&file, source).unwrap();
30409        let fence_start = source.find("```rust").unwrap();
30410        let body_start = source.find("fn sample").unwrap();
30411        let body_end = body_start + "fn sample() {}\n".len();
30412
30413        let response = empty_search_response(dir.path(), "lexical");
30414        let symbol_hits = vec![index::SymbolHit {
30415            name: "rust".to_string(),
30416            kind: "code_block".to_string(),
30417            language: "markdown".to_string(),
30418            file: file.to_string_lossy().to_string(),
30419            line: 2,
30420            end_line: Some(4),
30421            node_kind: Some("fenced_code_block".to_string()),
30422            start_byte: Some(i64::try_from(fence_start).unwrap()),
30423            end_byte: Some(i64::try_from(source.len()).unwrap()),
30424            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30425            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30426            tags: Some("rust".to_string()),
30427            score: 1.0,
30428            match_type: "exact_name".to_string(),
30429            tagpath_handle: None,
30430        }];
30431
30432        let report = build_relative_search_budget_report(
30433            "rust",
30434            "lexical",
30435            dir.path(),
30436            &response,
30437            &symbol_hits,
30438            ResponseBudget::new(Some(5), Some(96)),
30439            &SearchFacetFilters::default(),
30440        );
30441
30442        let embedded = &report.symbols[0]
30443            .ast
30444            .as_ref()
30445            .unwrap()
30446            .span
30447            .markdown
30448            .as_ref()
30449            .unwrap()
30450            .embedded_symbols;
30451        assert_eq!(embedded.len(), 1);
30452        assert_eq!(embedded[0].name, "sample");
30453        assert_eq!(embedded[0].kind, "function");
30454        assert_eq!(embedded[0].language, "rust");
30455        assert_eq!(embedded[0].node_kind, "function_item");
30456        assert!(embedded[0].handle.starts_with("span-"));
30457        assert_eq!(embedded[0].start_byte, body_start);
30458        assert_eq!(embedded[0].start_line, 4);
30459    }
30460
30461    fn test_lexical_search_hit(
30462        path: &Path,
30463        rank: usize,
30464        score: f64,
30465        snippet: &str,
30466    ) -> sift::SearchHit {
30467        sift::SearchHit {
30468            artifact_id: format!("hit-{rank}"),
30469            artifact_kind: sift::ContextArtifactKind::File,
30470            budget: sift::ArtifactBudget::from_text(snippet, 1),
30471            confidence: sift::ScoreConfidence::High,
30472            freshness: sift::ArtifactFreshness {
30473                modified_unix_secs: None,
30474                observed_unix_secs: 0,
30475            },
30476            location: Some("line 1".to_string()),
30477            path: path.to_string_lossy().to_string(),
30478            provenance: sift::ArtifactProvenance {
30479                adapter: sift::AcquisitionAdapterKind::FileSystem,
30480                source: "test lexical hit".to_string(),
30481                synthetic: false,
30482            },
30483            rank,
30484            score,
30485            snippet: snippet.to_string(),
30486        }
30487    }
30488
30489    fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
30490        summarize::Summary {
30491            id: 0,
30492            symbol_name: symbol_name.to_string(),
30493            file_path: file_path.to_string(),
30494            content_hash: "hash".to_string(),
30495            summary: summary.to_string(),
30496            entities: None,
30497            relationships: None,
30498            concept_labels: None,
30499            extracted_at: "2026-06-02T00:00:00Z".to_string(),
30500            model: "test".to_string(),
30501            tokens_input: None,
30502            tokens_output: None,
30503        }
30504    }
30505
30506    #[test]
30507    fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
30508        let dir = tempfile::tempdir().unwrap();
30509        let src_dir = dir.path().join("src");
30510        fs::create_dir_all(&src_dir).unwrap();
30511        let source = "fn alpha_helper() {}\n";
30512        let file = src_dir.join("lib.rs");
30513        let broad_file = dir.path().join("README.md");
30514        fs::write(&file, source).unwrap();
30515        fs::write(
30516            &broad_file,
30517            "alpha helper alpha helper alpha helper in prose\n",
30518        )
30519        .unwrap();
30520
30521        let mut response = empty_search_response(dir.path(), "lexical");
30522        response.hits.push(test_lexical_search_hit(
30523            &broad_file,
30524            1,
30525            240.0,
30526            "alpha helper alpha helper alpha helper in prose",
30527        ));
30528        let symbol_hits = vec![index::SymbolHit {
30529            name: "alpha_helper".to_string(),
30530            kind: "function".to_string(),
30531            language: "rust".to_string(),
30532            file: file.to_string_lossy().to_string(),
30533            line: 0,
30534            end_line: Some(0),
30535            node_kind: Some("function_item".to_string()),
30536            start_byte: Some(0),
30537            end_byte: Some(i64::try_from(source.len()).unwrap()),
30538            body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30539            body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30540            tags: Some("alpha,helper".to_string()),
30541            score: 0.8,
30542            match_type: "all_tags".to_string(),
30543            tagpath_handle: None,
30544        }];
30545
30546        let report = build_relative_search_budget_report(
30547            "alpha helper",
30548            "lexical",
30549            dir.path(),
30550            &response,
30551            &symbol_hits,
30552            ResponseBudget::new(Some(5), Some(128)),
30553            &SearchFacetFilters::default(),
30554        );
30555
30556        assert_eq!(report.ranked[0].source, "symbol_span");
30557        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30558        assert!(report.ranked[0].score > report.ranked[1].score);
30559        assert_eq!(report.ranked[1].source, "lexical_file");
30560    }
30561
30562    #[test]
30563    fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
30564        let dir = tempfile::tempdir().unwrap();
30565        let src_dir = dir.path().join("src");
30566        fs::create_dir_all(&src_dir).unwrap();
30567        let source = "fn alpha_helper() {\n    let needle = \"needle\";\n}\n\nfn other() {}\n";
30568        let file = src_dir.join("lib.rs");
30569        fs::write(&file, source).unwrap();
30570
30571        let mut response = empty_search_response(dir.path(), "exact");
30572        let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
30573        hit.location = Some("line 2".to_string());
30574        response.hits.push(hit);
30575
30576        let symbol_hits = vec![index::SymbolHit {
30577            name: "alpha_helper".to_string(),
30578            kind: "function".to_string(),
30579            language: "rust".to_string(),
30580            file: file.to_string_lossy().to_string(),
30581            line: 0,
30582            end_line: Some(2),
30583            node_kind: Some("function_item".to_string()),
30584            start_byte: Some(0),
30585            end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
30586            body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
30587            body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
30588            tags: Some("alpha,helper".to_string()),
30589            score: 0.9,
30590            match_type: "all_tags".to_string(),
30591            tagpath_handle: None,
30592        }];
30593
30594        let report = build_relative_search_budget_report(
30595            "needle",
30596            "exact",
30597            dir.path(),
30598            &response,
30599            &symbol_hits,
30600            ResponseBudget::new(Some(5), Some(128)),
30601            &SearchFacetFilters::default(),
30602        );
30603
30604        let hit = &report.hits[0];
30605        assert_eq!(hit.line, Some(2));
30606        let source_handle = hit
30607            .source_handle
30608            .as_ref()
30609            .expect("exact hit should expose a bounded source_handle window");
30610        assert!(source_handle.handle.starts_with("xwin-"));
30611        assert_eq!(source_handle.kind, "source_handle");
30612        assert_eq!(source_handle.file, "src/lib.rs");
30613        assert_eq!(source_handle.start_line, 1);
30614        assert_eq!(source_handle.end_line, 3);
30615        assert!(source_handle.expand.contains("source-read"));
30616
30617        let containing_symbol = hit
30618            .containing_symbol
30619            .as_ref()
30620            .expect("exact hit should expose its containing symbol when indexed");
30621        assert_eq!(containing_symbol.name, "alpha_helper");
30622        assert_eq!(containing_symbol.kind, "function");
30623        assert_eq!(containing_symbol.line, 1);
30624        assert_eq!(containing_symbol.end_line, Some(3));
30625        assert!(containing_symbol.expand.contains("symbol-read"));
30626
30627        let lexical_rank = report
30628            .ranked
30629            .iter()
30630            .find(|item| item.source == "lexical_file")
30631            .expect("ranked preview should retain the lexical retrieval handle");
30632        assert!(
30633            lexical_rank
30634                .reasons
30635                .iter()
30636                .any(|reason| reason == "source_handle")
30637        );
30638        assert!(
30639            lexical_rank
30640                .reasons
30641                .iter()
30642                .any(|reason| reason == "containing_symbol")
30643        );
30644    }
30645
30646    #[test]
30647    fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
30648        let dir = tempfile::tempdir().unwrap();
30649        let src_dir = dir.path().join("src");
30650        let tests_dir = dir.path().join("tests");
30651        fs::create_dir_all(&src_dir).unwrap();
30652        fs::create_dir_all(&tests_dir).unwrap();
30653        let source_file = src_dir.join("lib.rs");
30654        let test_file = tests_dir.join("alpha_test.rs");
30655        fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
30656        fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
30657
30658        let response = empty_search_response(dir.path(), "lexical");
30659        let symbol_hits = vec![
30660            index::SymbolHit {
30661                name: "alpha_helper_test".to_string(),
30662                kind: "function".to_string(),
30663                language: "rust".to_string(),
30664                file: test_file.to_string_lossy().to_string(),
30665                line: 1,
30666                end_line: Some(1),
30667                node_kind: Some("function_item".to_string()),
30668                start_byte: Some(8),
30669                end_byte: Some(33),
30670                body_start_byte: Some(31),
30671                body_end_byte: Some(31),
30672                tags: Some("alpha,helper,test".to_string()),
30673                score: 1.0,
30674                match_type: "exact_name".to_string(),
30675                tagpath_handle: None,
30676            },
30677            index::SymbolHit {
30678                name: "alpha_helper".to_string(),
30679                kind: "function".to_string(),
30680                language: "rust".to_string(),
30681                file: source_file.to_string_lossy().to_string(),
30682                line: 0,
30683                end_line: Some(0),
30684                node_kind: Some("function_item".to_string()),
30685                start_byte: Some(0),
30686                end_byte: Some(20),
30687                body_start_byte: Some(18),
30688                body_end_byte: Some(18),
30689                tags: Some("alpha,helper".to_string()),
30690                score: 0.78,
30691                match_type: "all_tags".to_string(),
30692                tagpath_handle: None,
30693            },
30694        ];
30695
30696        let report = build_relative_search_budget_report(
30697            "alpha helper",
30698            "lexical",
30699            dir.path(),
30700            &response,
30701            &symbol_hits,
30702            ResponseBudget::new(Some(5), Some(128)),
30703            &SearchFacetFilters::default(),
30704        );
30705
30706        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30707        assert_eq!(report.ranked[0].path, "src/lib.rs");
30708        assert!(
30709            report.ranked[0]
30710                .reasons
30711                .iter()
30712                .any(|reason| reason == "definition_kind")
30713        );
30714        assert!(
30715            report.ranked[0]
30716                .reasons
30717                .iter()
30718                .any(|reason| reason == "source_path")
30719        );
30720        let test_rank = report
30721            .ranked
30722            .iter()
30723            .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
30724            .expect("test symbol should still be present in the ranked preview");
30725        assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
30726    }
30727
30728    #[test]
30729    fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
30730        let dir = tempfile::tempdir().unwrap();
30731        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30732        let file = dir.path().join("README.md");
30733        fs::write(&file, source).unwrap();
30734        let summary_db =
30735            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
30736        summary_db
30737            .insert(&test_summary(
30738                "rust",
30739                "README.md",
30740                "Rust fence contains a sample function.",
30741            ))
30742            .unwrap();
30743
30744        let fence_start = source.find("```rust").unwrap();
30745        let body_start = source.find("fn sample").unwrap();
30746        let body_end = body_start + "fn sample() {}\n".len();
30747        let response = empty_search_response(dir.path(), "lexical");
30748        let symbol_hits = vec![index::SymbolHit {
30749            name: "rust".to_string(),
30750            kind: "code_block".to_string(),
30751            language: "markdown".to_string(),
30752            file: file.to_string_lossy().to_string(),
30753            line: 2,
30754            end_line: Some(4),
30755            node_kind: Some("fenced_code_block".to_string()),
30756            start_byte: Some(i64::try_from(fence_start).unwrap()),
30757            end_byte: Some(i64::try_from(source.len()).unwrap()),
30758            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30759            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30760            tags: Some("rust".to_string()),
30761            score: 1.0,
30762            match_type: "exact_name".to_string(),
30763            tagpath_handle: None,
30764        }];
30765
30766        let report = build_relative_search_budget_report(
30767            "rust",
30768            "lexical",
30769            dir.path(),
30770            &response,
30771            &symbol_hits,
30772            ResponseBudget::new(Some(5), Some(128)),
30773            &SearchFacetFilters::default(),
30774        );
30775
30776        let symbol = &report.symbols[0];
30777        assert_eq!(symbol.summary_refs, 1);
30778        assert_eq!(symbol.graph_neighbors, 1);
30779        assert!(
30780            report.ranked[0]
30781                .reasons
30782                .iter()
30783                .any(|reason| reason == "summary_refs:1")
30784        );
30785        assert!(
30786            report.ranked[0]
30787                .reasons
30788                .iter()
30789                .any(|reason| reason == "graph_neighbors:1")
30790        );
30791    }
30792
30793    fn markdown_search_facet_fixture() -> tempfile::TempDir {
30794        let dir = tempfile::tempdir().unwrap();
30795        let source = r#"# Guide
30796
30797## Install
30798
30799- Run setup.
30800  - Confirm setup.
30801
30802```rust
30803fn sample() {}
30804```
30805"#;
30806        fs::write(dir.path().join("README.md"), source).unwrap();
30807        let index_dir = dir.path().join(".tsift");
30808        fs::create_dir_all(&index_dir).unwrap();
30809        run_index_update(
30810            &index_dir.join("index.db"),
30811            dir.path(),
30812            "indexing markdown search facet fixture".to_string(),
30813            dir.path(),
30814            None,
30815            false,
30816            false,
30817        )
30818        .unwrap();
30819        dir
30820    }
30821
30822    fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
30823        let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
30824        db.symbol_search(query, 20).unwrap()
30825    }
30826
30827    #[test]
30828    fn search_facet_filters_match_scalar_symbol_fields() {
30829        let dir = tempfile::tempdir().unwrap();
30830        let hits = vec![
30831            index::SymbolHit {
30832                name: "alpha_helper".to_string(),
30833                kind: "function".to_string(),
30834                language: "rust".to_string(),
30835                file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
30836                line: 0,
30837                end_line: None,
30838                node_kind: Some("function_item".to_string()),
30839                start_byte: None,
30840                end_byte: None,
30841                body_start_byte: None,
30842                body_end_byte: None,
30843                tags: None,
30844                score: 1.0,
30845                match_type: "exact_name".to_string(),
30846                tagpath_handle: None,
30847            },
30848            index::SymbolHit {
30849                name: "Install".to_string(),
30850                kind: "heading".to_string(),
30851                language: "markdown".to_string(),
30852                file: dir.path().join("README.md").to_string_lossy().to_string(),
30853                line: 0,
30854                end_line: None,
30855                node_kind: Some("atx_heading".to_string()),
30856                start_byte: None,
30857                end_byte: None,
30858                body_start_byte: None,
30859                body_end_byte: None,
30860                tags: None,
30861                score: 0.9,
30862                match_type: "exact_name".to_string(),
30863                tagpath_handle: None,
30864            },
30865        ];
30866
30867        let filtered = apply_search_facet_filters(
30868            dir.path(),
30869            hits,
30870            &SearchFacetFilters {
30871                languages: vec!["rust".to_string()],
30872                kinds: vec!["function".to_string()],
30873                node_kinds: vec!["function_item".to_string()],
30874                ..SearchFacetFilters::default()
30875            },
30876        );
30877
30878        assert_eq!(filtered.len(), 1);
30879        assert_eq!(filtered[0].name, "alpha_helper");
30880    }
30881
30882    #[test]
30883    fn search_facet_filters_match_markdown_sections_and_block_metadata() {
30884        let dir = markdown_search_facet_fixture();
30885
30886        let nested_list = apply_search_facet_filters(
30887            dir.path(),
30888            markdown_search_facet_hits(dir.path(), "setup"),
30889            &SearchFacetFilters {
30890                sections: vec!["Install".to_string()],
30891                parents: vec!["Run setup.".to_string()],
30892                list_depths: vec![1],
30893                ..SearchFacetFilters::default()
30894            },
30895        );
30896        assert_eq!(nested_list.len(), 1);
30897        assert_eq!(nested_list[0].name, "Confirm setup.");
30898
30899        let parent_list = apply_search_facet_filters(
30900            dir.path(),
30901            markdown_search_facet_hits(dir.path(), "setup"),
30902            &SearchFacetFilters {
30903                children: vec!["Confirm setup.".to_string()],
30904                ..SearchFacetFilters::default()
30905            },
30906        );
30907        assert_eq!(parent_list.len(), 1);
30908        assert_eq!(parent_list[0].name, "Run setup.");
30909
30910        let heading = apply_search_facet_filters(
30911            dir.path(),
30912            markdown_search_facet_hits(dir.path(), "Install"),
30913            &SearchFacetFilters {
30914                heading_levels: vec![2],
30915                node_kinds: vec!["atx_heading".to_string()],
30916                ..SearchFacetFilters::default()
30917            },
30918        );
30919        assert_eq!(heading.len(), 1);
30920        assert_eq!(heading[0].name, "Install");
30921
30922        let fence = apply_search_facet_filters(
30923            dir.path(),
30924            markdown_search_facet_hits(dir.path(), "rust"),
30925            &SearchFacetFilters {
30926                fence_languages: vec!["rust".to_string()],
30927                kinds: vec!["code_block".to_string()],
30928                ..SearchFacetFilters::default()
30929            },
30930        );
30931        assert_eq!(fence.len(), 1);
30932        assert_eq!(fence[0].kind, "code_block");
30933
30934        let embedded_child = apply_search_facet_filters(
30935            dir.path(),
30936            markdown_search_facet_hits(dir.path(), "rust"),
30937            &SearchFacetFilters {
30938                children: vec!["sample".to_string()],
30939                kinds: vec!["code_block".to_string()],
30940                ..SearchFacetFilters::default()
30941            },
30942        );
30943        assert_eq!(embedded_child.len(), 1);
30944        assert_eq!(embedded_child[0].name, "rust");
30945    }
30946
30947    #[test]
30948    fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
30949        let response = empty_search_response(Path::new("/repo"), "lexical");
30950        let symbol_hits = vec![
30951            index::SymbolHit {
30952                name: "alpha_helper".to_string(),
30953                kind: "function".to_string(),
30954                language: "rust".to_string(),
30955                file: "/repo/src/lib.rs".to_string(),
30956                line: 12,
30957                end_line: None,
30958                node_kind: None,
30959                start_byte: None,
30960                end_byte: None,
30961                body_start_byte: None,
30962                body_end_byte: None,
30963                tags: Some("alpha,helper".to_string()),
30964                score: 0.98,
30965                match_type: "exact_name".to_string(),
30966                tagpath_handle: None,
30967            },
30968            index::SymbolHit {
30969                name: "alphaHelper".to_string(),
30970                kind: "method".to_string(),
30971                language: "rust".to_string(),
30972                file: "/repo/src/main.rs".to_string(),
30973                line: 34,
30974                end_line: None,
30975                node_kind: None,
30976                start_byte: None,
30977                end_byte: None,
30978                body_start_byte: None,
30979                body_end_byte: None,
30980                tags: Some("alpha,helper".to_string()),
30981                score: 0.93,
30982                match_type: "tag_overlap".to_string(),
30983                tagpath_handle: None,
30984            },
30985            index::SymbolHit {
30986                name: "alpha_helper".to_string(),
30987                kind: "function".to_string(),
30988                language: "rust".to_string(),
30989                file: "/repo/src/worker.rs".to_string(),
30990                line: 56,
30991                end_line: None,
30992                node_kind: None,
30993                start_byte: None,
30994                end_byte: None,
30995                body_start_byte: None,
30996                body_end_byte: None,
30997                tags: Some("alpha,helper".to_string()),
30998                score: 0.91,
30999                match_type: "tag_overlap".to_string(),
31000                tagpath_handle: None,
31001            },
31002        ];
31003
31004        let report = build_relative_search_budget_report(
31005            "alpha helper",
31006            "lexical",
31007            Path::new("/repo"),
31008            &response,
31009            &symbol_hits,
31010            ResponseBudget::new(Some(5), Some(48)),
31011            &SearchFacetFilters::default(),
31012        );
31013
31014        assert_eq!(report.symbol_total, 1);
31015        assert_eq!(report.raw_symbol_total, 3);
31016        assert_eq!(report.symbols.len(), 1);
31017        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
31018        assert_eq!(report.symbols[0].match_count, 3);
31019        assert_eq!(report.symbols[0].surface_count, 2);
31020        assert_eq!(report.symbols[0].file_count, 3);
31021        assert_eq!(
31022            report.symbols[0].surface_examples,
31023            vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
31024        );
31025        assert!(report.symbols[0].name.contains("(+1 variant)"));
31026        assert!(report.symbols[0].file.contains("(+2 files)"));
31027        assert!(report.symbols[0].expand.contains("tsift search"));
31028        assert!(report.symbols[0].expand.contains("alpha helper"));
31029    }
31030
31031    #[test]
31032    fn search_budget_report_carries_active_filters() {
31033        let response = empty_search_response(Path::new("/repo"), "lexical");
31034        let symbol_hits = vec![index::SymbolHit {
31035            name: "alpha_helper".to_string(),
31036            kind: "function".to_string(),
31037            language: "rust".to_string(),
31038            file: "/repo/src/lib.rs".to_string(),
31039            line: 12,
31040            end_line: None,
31041            node_kind: Some("function_item".to_string()),
31042            start_byte: None,
31043            end_byte: None,
31044            body_start_byte: None,
31045            body_end_byte: None,
31046            tags: Some("alpha,helper".to_string()),
31047            score: 0.98,
31048            match_type: "exact_name".to_string(),
31049            tagpath_handle: None,
31050        }];
31051        let filters = SearchFacetFilters {
31052            languages: vec!["rust".to_string()],
31053            kinds: vec!["function".to_string()],
31054            node_kinds: vec!["function_item".to_string()],
31055            ..SearchFacetFilters::default()
31056        };
31057
31058        let report = build_relative_search_budget_report(
31059            "alpha helper",
31060            "lexical",
31061            Path::new("/repo"),
31062            &response,
31063            &symbol_hits,
31064            ResponseBudget::new(Some(5), Some(48)),
31065            &filters,
31066        );
31067
31068        assert_eq!(report.filters, filters);
31069        assert_eq!(
31070            search_facet_filters_summary(&report.filters),
31071            "lang=rust kind=function node-kind=function_item"
31072        );
31073    }
31074
31075    #[test]
31076    fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
31077        let mut response = empty_search_response(Path::new("/repo"), "lexical");
31078        response.indexed_artifacts = 450;
31079        let symbol_hits = vec![
31080            index::SymbolHit {
31081                name: "alpha_helper".to_string(),
31082                kind: "function".to_string(),
31083                language: "rust".to_string(),
31084                file: "/repo/src/lib.rs".to_string(),
31085                line: 12,
31086                end_line: None,
31087                node_kind: None,
31088                start_byte: None,
31089                end_byte: None,
31090                body_start_byte: None,
31091                body_end_byte: None,
31092                tags: Some("alpha,helper".to_string()),
31093                score: 0.98,
31094                match_type: "exact_name".to_string(),
31095                tagpath_handle: None,
31096            },
31097            index::SymbolHit {
31098                name: "beta_helper".to_string(),
31099                kind: "function".to_string(),
31100                language: "rust".to_string(),
31101                file: "/repo/src/beta.rs".to_string(),
31102                line: 21,
31103                end_line: None,
31104                node_kind: None,
31105                start_byte: None,
31106                end_byte: None,
31107                body_start_byte: None,
31108                body_end_byte: None,
31109                tags: Some("beta,helper".to_string()),
31110                score: 0.92,
31111                match_type: "tag_overlap".to_string(),
31112                tagpath_handle: None,
31113            },
31114        ];
31115
31116        let report = build_relative_search_budget_report(
31117            "helper",
31118            "lexical",
31119            Path::new("/repo"),
31120            &response,
31121            &symbol_hits,
31122            ResponseBudget::new(Some(1), Some(64)),
31123            &SearchFacetFilters::default(),
31124        );
31125
31126        let guard = report
31127            .scale_guard
31128            .as_ref()
31129            .expect("broad previews should emit a scale guard");
31130        assert_eq!(guard.level, "high-hit");
31131        assert_eq!(guard.signals.indexed_artifacts, 450);
31132        assert_eq!(guard.signals.raw_symbol_matches, 2);
31133        assert!(
31134            guard
31135                .narrow_commands
31136                .iter()
31137                .any(|command| command.contains("--exact"))
31138        );
31139        assert!(
31140            guard
31141                .narrow_commands
31142                .iter()
31143                .any(|command| command.contains("alpha helper"))
31144        );
31145        assert!(
31146            guard
31147                .narrow_commands
31148                .last()
31149                .unwrap()
31150                .contains("workflow search")
31151        );
31152    }
31153
31154    #[test]
31155    fn explain_budget_report_limits_edges_and_members() {
31156        let symbols = vec![index::StoredSymbol {
31157            name: "alpha_helper".to_string(),
31158            kind: "function".to_string(),
31159            language: "rust".to_string(),
31160            signature: None,
31161            file: "src/lib.rs".to_string(),
31162            line: 10,
31163            end_line: None,
31164            node_kind: None,
31165            start_byte: None,
31166            end_byte: None,
31167            body_start_byte: None,
31168            body_end_byte: None,
31169            parent_module: None,
31170            visibility: None,
31171            tags: None,
31172            tagpath_handle: None,
31173        }];
31174        let callers = vec![
31175            index::StoredEdge {
31176                caller_file: "src/main.rs".to_string(),
31177                caller_name: "main".to_string(),
31178                caller_line: 1,
31179                callee_name: "alpha_helper".to_string(),
31180                call_site_line: 3,
31181                tagpath_handle: None,
31182            },
31183            index::StoredEdge {
31184                caller_file: "src/worker.rs".to_string(),
31185                caller_name: "worker".to_string(),
31186                caller_line: 5,
31187                callee_name: "alpha_helper".to_string(),
31188                call_site_line: 8,
31189                tagpath_handle: None,
31190            },
31191        ];
31192        let community = graph::Community {
31193            id: 1,
31194            members: vec![
31195                graph::CommunityMember::new("alpha_helper"),
31196                graph::CommunityMember::new("main"),
31197                graph::CommunityMember::new("worker"),
31198            ],
31199            modularity_contribution: 0.5,
31200        };
31201
31202        let report = build_explain_budget_report(
31203            "alpha_helper",
31204            Path::new("/repo"),
31205            &symbols,
31206            &callers,
31207            2,
31208            false,
31209            &[],
31210            0,
31211            false,
31212            Some(&community),
31213            ResponseBudget::new(Some(1), Some(24)),
31214        );
31215
31216        assert_eq!(report.definitions.len(), 1);
31217        assert_eq!(report.callers.len(), 1);
31218        assert!(report.truncated);
31219        assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
31220        assert_eq!(
31221            report.definitions[0].tag_alias.as_deref(),
31222            Some("alpha/helper")
31223        );
31224        assert!(report.callers[0].handle.starts_with("ecall-"));
31225        assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
31226    }
31227
31228    #[test]
31229    fn session_review_next_context_budget_limits_lists() {
31230        let report = session_review::SessionReviewReport {
31231            root: "/repo".to_string(),
31232            target: "tasks/software/tsift.md".to_string(),
31233            target_kind: "file".to_string(),
31234            sessions_considered: 1,
31235            sessions_matched: 1,
31236            claude_sessions: 1,
31237            codex_sessions: 0,
31238            agent_doc_logs: 0,
31239            prompt_target_count: 2,
31240            command_groups: 0,
31241            file_groups: 2,
31242            symbol_groups: 1,
31243            failure_groups: 1,
31244            runtime_event_groups: 0,
31245            restart_churn_groups: 0,
31246            closeout_groups: 0,
31247            usage_samples: 1,
31248            prompt_tokens: 120,
31249            cached_input_tokens: 80,
31250            cache_creation_input_tokens: 0,
31251            output_tokens: 40,
31252            reasoning_output_tokens: 0,
31253            total_tokens: 240,
31254            cached_input_ratio: Some(40.0),
31255            largest_turn_total_tokens: 240,
31256            aggregate_cost: session_review::SessionReviewCostSummary {
31257                scope: "bounded_matched_sessions".to_string(),
31258                sessions: 1,
31259                usage_samples: 1,
31260                prompt_tokens: 120,
31261                cached_input_tokens: 80,
31262                cache_creation_input_tokens: 0,
31263                output_tokens: 40,
31264                reasoning_output_tokens: 0,
31265                total_tokens: 240,
31266                cached_input_ratio: Some(40.0),
31267                largest_turn_total_tokens: 240,
31268            },
31269            latest_session_cost: Some(session_review::SessionReviewCostSummary {
31270                scope: "latest_matched_session".to_string(),
31271                sessions: 1,
31272                usage_samples: 1,
31273                prompt_tokens: 120,
31274                cached_input_tokens: 80,
31275                cache_creation_input_tokens: 0,
31276                output_tokens: 40,
31277                reasoning_output_tokens: 0,
31278                total_tokens: 240,
31279                cached_input_ratio: Some(66.67),
31280                largest_turn_total_tokens: 240,
31281            }),
31282            prompt_cache_cross_run: None,
31283            prompt_cache_roi_scorecard: vec![],
31284            guardrails: vec![
31285                session_cost::SessionCostGuardrail {
31286                    kind: "cache_resend".to_string(),
31287                    severity: "warn".to_string(),
31288                    message: "cached input ratio was high".to_string(),
31289                    guidance: "compact or restart the session".to_string(),
31290                },
31291                session_cost::SessionCostGuardrail {
31292                    kind: "prompt_budget".to_string(),
31293                    severity: "warn".to_string(),
31294                    message: "largest prompt turn reached 999999 tokens".to_string(),
31295                    guidance: "compact the session before another large turn".to_string(),
31296                },
31297                session_cost::SessionCostGuardrail {
31298                    kind: "restart_loop".to_string(),
31299                    severity: "warn".to_string(),
31300                    message: "restart churn detected".to_string(),
31301                    guidance: "restart cleanly".to_string(),
31302                },
31303                session_cost::SessionCostGuardrail {
31304                    kind: "noop_closeout".to_string(),
31305                    severity: "warn".to_string(),
31306                    message: "commit_already_current appeared 8 times".to_string(),
31307                    guidance: "avoid reopening without new edits".to_string(),
31308                },
31309            ],
31310            loop_clusters: vec![session_cost::SessionCostLoopCluster {
31311                kind: "command_bundle".to_string(),
31312                label: "cargo test -> cargo build --release".to_string(),
31313                occurrences: 2,
31314                max_consecutive: 2,
31315            }],
31316            file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
31317                path: "src/lib.rs".to_string(),
31318                range: "12-40".to_string(),
31319                occurrences: 3,
31320                estimated_tokens: 1200,
31321                duplicate_estimated_tokens: 800,
31322                follow_up_commands: vec![
31323                    "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
31324                        .to_string(),
31325                ],
31326            }],
31327            prompt_targets: vec![
31328                session_review::SessionReviewPromptTarget {
31329                    text: "do one".to_string(),
31330                    occurrences: 1,
31331                },
31332                session_review::SessionReviewPromptTarget {
31333                    text: "do two".to_string(),
31334                    occurrences: 1,
31335                },
31336            ],
31337            commands: vec![],
31338            touched_files: vec![],
31339            touched_symbols: vec![],
31340            failures: vec![],
31341            runtime_events: vec![],
31342            restart_churn: vec![],
31343            closeout: vec![],
31344            largest_turns: vec![],
31345            sessions: vec![session_review::SessionReviewSession {
31346                source: "claude_jsonl".to_string(),
31347                path: "/tmp/session.jsonl".to_string(),
31348                matched_by: vec!["path".to_string()],
31349                modified_unix_secs: None,
31350                prompt_target_count: 2,
31351                command_groups: 0,
31352                file_groups: 2,
31353                symbol_groups: 1,
31354                failure_groups: 1,
31355                runtime_event_groups: 0,
31356                restart_churn_groups: 0,
31357                closeout_groups: 0,
31358                usage_samples: 1,
31359                prompt_tokens: 120,
31360                cached_input_tokens: 80,
31361                cache_creation_input_tokens: 0,
31362                output_tokens: 40,
31363                reasoning_output_tokens: 0,
31364                total_tokens: 240,
31365                largest_turn_total_tokens: 240,
31366            }],
31367            next_context: session_review::SessionReviewNextContext {
31368                target: "tasks/software/tsift.md".to_string(),
31369                active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
31370                last_verification: session_review::SessionReviewVerificationState {
31371                    status: "green".to_string(),
31372                    detail: "cargo test".to_string(),
31373                },
31374                touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
31375                touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
31376                unresolved_failures: vec![session_review::SessionReviewFailure {
31377                    kind: "timeout".to_string(),
31378                    message: "search timed out".to_string(),
31379                    occurrences: 1,
31380                    command: None,
31381                    session_path: None,
31382                }],
31383                agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
31384                    active_queue_prompt: Some(
31385                        "[#one] do one with enough detail to truncate".to_string(),
31386                    ),
31387                    live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
31388                    backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
31389                    review_rows: vec![
31390                        "[#review] review one".to_string(),
31391                        "[#review2] review two".to_string(),
31392                    ],
31393                    prompt_presets: vec![
31394                        "#spec-test-build-install-commit-push: update spec + tests"
31395                            .to_string(),
31396                        "#next-steps: collect follow-ups".to_string(),
31397                    ],
31398                    expansion_handles: vec![
31399                        session_review::SessionReviewAgentDocExpansionHandle {
31400                            handle: "adq-next-context".to_string(),
31401                            label: "refresh next-context".to_string(),
31402                            expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
31403                        },
31404                        session_review::SessionReviewAgentDocExpansionHandle {
31405                            handle: "adq-context-pack".to_string(),
31406                            label: "refresh context-pack".to_string(),
31407                            expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
31408                        },
31409                    ],
31410                }),
31411                prompt_cache_health: None,
31412                next_digest_commands: vec![
31413                    "tsift session-review --next-context tasks/software/tsift.md".to_string(),
31414                    "tsift diff-digest .".to_string(),
31415                    "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
31416                    "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
31417                ],
31418            },
31419            warnings: vec![],
31420        };
31421
31422        let budget_report = build_session_review_next_context_budget_report(
31423            &report,
31424            ResponseBudget::new(Some(1), Some(12)),
31425            None,
31426        );
31427
31428        assert!(budget_report.truncated);
31429        assert_eq!(budget_report.prompt_targets, vec!["do one"]);
31430        assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
31431        assert!(
31432            budget_report.touched_symbol_refs[0]
31433                .handle
31434                .starts_with("ncsym-")
31435        );
31436        assert_eq!(
31437            budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
31438            Some("alpha/helper")
31439        );
31440        assert!(
31441            budget_report.unresolved_failures[0]
31442                .handle
31443                .starts_with("snf-")
31444        );
31445        assert_eq!(budget_report.next_digest_commands.len(), 4);
31446        assert_eq!(
31447            budget_report.next_digest_commands[2],
31448            "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
31449        );
31450        let queue = budget_report
31451            .agent_doc_queue
31452            .as_ref()
31453            .expect("agent-doc queue budget profile should be present");
31454        assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
31455        assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
31456        assert_eq!(queue.review_row_total, 2);
31457        assert_eq!(queue.prompt_presets.len(), 1);
31458        assert_eq!(queue.expansion_handles.len(), 2);
31459        assert!(queue.truncated);
31460        assert_eq!(budget_report.next_token_actions.len(), 1);
31461        assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
31462
31463        let full_action_report = build_session_review_next_context_budget_report(
31464            &report,
31465            ResponseBudget::new(Some(6), Some(120)),
31466            None,
31467        );
31468        assert_eq!(
31469            full_action_report
31470                .next_token_actions
31471                .iter()
31472                .map(|action| action.kind.as_str())
31473                .collect::<Vec<_>>(),
31474            vec![
31475                "prompt_budget",
31476                "cache_resend",
31477                "repeated_raw_read",
31478                "repeated_command_bundle",
31479                "restart_loop",
31480                "noop_closeout"
31481            ]
31482        );
31483        assert_eq!(
31484            full_action_report.next_token_actions[0]
31485                .compact_command
31486                .as_deref(),
31487            Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
31488        );
31489        assert_eq!(
31490            full_action_report.next_token_actions[0]
31491                .restart_command
31492                .as_deref(),
31493            Some("agent-doc start \"tasks/software/tsift.md\"")
31494        );
31495        assert!(
31496            full_action_report.next_token_actions[0]
31497                .digest_commands
31498                .iter()
31499                .any(|command| command
31500                    == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
31501        );
31502        let raw_read_action = full_action_report
31503            .next_token_actions
31504            .iter()
31505            .find(|action| action.kind == "repeated_raw_read")
31506            .expect("raw read action");
31507        assert!(
31508            raw_read_action.rewrite_commands.iter().any(
31509                |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
31510            ),
31511            "raw read rewrite commands: {:?}",
31512            raw_read_action.rewrite_commands
31513        );
31514        assert!(raw_read_action.rewrite_commands.iter().any(|command| command
31515        == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
31516        let command_bundle_action = full_action_report
31517            .next_token_actions
31518            .iter()
31519            .find(|action| action.kind == "repeated_command_bundle")
31520            .expect("command bundle action");
31521        assert!(
31522            command_bundle_action
31523                .rewrite_commands
31524                .iter()
31525                .any(|command| command == "tsift rewrite --run \"cargo test\"")
31526        );
31527        assert!(
31528            command_bundle_action
31529                .rewrite_commands
31530                .iter()
31531                .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
31532        );
31533    }
31534
31535    #[test]
31536    fn context_pack_diff_preview_limits_files_and_symbols() {
31537        let report = diff_digest::DiffDigestReport {
31538            root: "/repo".to_string(),
31539            mode: diff_digest::DiffDigestMode::WorkingTree,
31540            revision: None,
31541            files_changed: 2,
31542            files_with_current_summaries: 1,
31543            symbols_touched: 3,
31544            call_edges_added: 1,
31545            call_edges_removed: 0,
31546            files: vec![
31547                diff_digest::DiffDigestFile {
31548                    path: "src/lib.rs".to_string(),
31549                    status: diff_digest::DiffDigestFileStatus::Modified,
31550                    touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
31551                    summary_state: diff_digest::DiffDigestSummaryState::Current,
31552                    current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31553                        symbol: "alpha_helper".to_string(),
31554                        summary: "alpha helper handles the main alpha workflow".to_string(),
31555                    }],
31556                    added_call_edges: vec!["alpha->beta".to_string()],
31557                    removed_call_edges: vec![],
31558                    warnings: vec!["stale parse".to_string()],
31559                },
31560                diff_digest::DiffDigestFile {
31561                    path: "src/main.rs".to_string(),
31562                    status: diff_digest::DiffDigestFileStatus::Added,
31563                    touched_symbols: vec!["main".to_string()],
31564                    summary_state: diff_digest::DiffDigestSummaryState::Missing,
31565                    current_summaries: vec![],
31566                    added_call_edges: vec![],
31567                    removed_call_edges: vec![],
31568                    warnings: vec![],
31569                },
31570            ],
31571        };
31572
31573        let preview =
31574            build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
31575
31576        assert!(preview.truncated);
31577        assert_eq!(preview.files.len(), 1);
31578        assert_eq!(preview.files[0].path, "src/lib.rs");
31579        assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
31580        assert!(
31581            preview.files[0].touched_symbol_refs[0]
31582                .handle
31583                .starts_with("cdsym-")
31584        );
31585        assert_eq!(
31586            preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
31587            Some("alpha/he...")
31588        );
31589        assert!(
31590            preview.files[0].summary_refs[0]
31591                .handle
31592                .starts_with("cdsum-")
31593        );
31594        assert_eq!(
31595            preview.files[0].summary_refs[0].tag_alias.as_deref(),
31596            Some("alpha/he...")
31597        );
31598        assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
31599        assert_eq!(
31600            preview.files[0].summary_refs[0].expand,
31601            "tsift summarize --file \"src/lib.rs\""
31602        );
31603        assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
31604    }
31605
31606    #[test]
31607    fn context_pack_status_reminders_include_stale_index_state() {
31608        let dir = setup_graph_index();
31609        std::thread::sleep(std::time::Duration::from_millis(50));
31610        std::fs::write(
31611            dir.path().join("main.rs"),
31612            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
31613        )
31614        .unwrap();
31615
31616        let reminders = context_pack_status_reminders(dir.path());
31617
31618        assert_eq!(reminders.len(), 1);
31619        assert!(reminders[0].contains("index stale"));
31620        assert!(reminders[0].contains("tsift index ."));
31621    }
31622
31623    // #gdbgatecold regression-lock: the trusted context-pack pipeline must
31624    // share its index-inspection across `prepare_agent_doc_index_gate` and
31625    // `context_pack_status_reminders` (both call `IndexDb::inspect_read_only`
31626    // on the same `(root, .tsift/index.db)` key). With the scope guard
31627    // active in `build_context_pack_report_with_profile`, the second call
31628    // hits the cache, so we should record one miss and at least one hit.
31629    #[test]
31630    fn build_context_pack_reuses_inspect_within_scope() {
31631        let dir = setup_graph_index();
31632        init_git_repo(dir.path());
31633        let _guard = index::InspectScopeGuard::new();
31634        let _ = build_context_pack_report(
31635            dir.path(),
31636            None,
31637            None,
31638            None,
31639            ResponseBudget::new(Some(2), Some(96)),
31640        )
31641        .unwrap();
31642        let (hits, misses) = index::inspect_scope_stats();
31643        assert!(
31644            hits >= 1,
31645            "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
31646        );
31647        assert!(
31648            misses >= 1,
31649            "expected at least one initial inspect miss (hits={hits}, misses={misses})"
31650        );
31651    }
31652
31653    // #gdbgatecold scope-isolation: outside of any scope, every call to
31654    // `IndexDb::inspect_read_only` must hit the disk fresh. This locks in
31655    // the contract that the search/status fast-paths never reuse a cached
31656    // inspection across consecutive top-level calls.
31657    #[test]
31658    fn inspect_read_only_outside_scope_does_not_cache() {
31659        let dir = setup_graph_index();
31660        let db_path = dir.path().join(".tsift/index.db");
31661        let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31662        let (hits, misses) = index::inspect_scope_stats();
31663        assert_eq!(
31664            (hits, misses),
31665            (0, 0),
31666            "no scope guard => no hits/misses recorded"
31667        );
31668        let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31669        let (hits, _) = index::inspect_scope_stats();
31670        assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
31671    }
31672
31673    #[test]
31674    fn context_pack_refreshes_stale_index_before_handoff() {
31675        let dir = setup_graph_index();
31676        init_git_repo(dir.path());
31677        std::thread::sleep(std::time::Duration::from_millis(50));
31678        std::fs::write(
31679            dir.path().join("main.rs"),
31680            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
31681        )
31682        .unwrap();
31683
31684        let report = build_context_pack_report(
31685            dir.path(),
31686            None,
31687            None,
31688            None,
31689            ResponseBudget::new(Some(2), Some(96)),
31690        )
31691        .unwrap();
31692
31693        assert!(
31694            report
31695                .status_reminders
31696                .iter()
31697                .any(|reminder| reminder.contains("index refreshed")
31698                    && reminder.contains("context-pack handoff")),
31699            "expected context-pack refresh diagnostic, got {:?}",
31700            report.status_reminders
31701        );
31702        assert!(
31703            !report
31704                .status_reminders
31705                .iter()
31706                .any(|reminder| reminder.contains("index stale")),
31707            "stale reminder should be gone after refresh: {:?}",
31708            report.status_reminders
31709        );
31710
31711        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
31712        let summary = db.compute_changes(dir.path()).unwrap();
31713        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
31714    }
31715
31716    #[test]
31717    fn context_pack_materializes_source_handles_into_graph_store() {
31718        let dir = tempfile::tempdir().unwrap();
31719        let packet = ExplorationPacket {
31720            budget: exploration_budget_for_counts(2, 1),
31721            relationship_map: vec![ExplorationRelation {
31722                from: "file:main.rs".to_string(),
31723                relation: "touches_symbol".to_string(),
31724                to: "symbol:helper".to_string(),
31725                label: Some("modified diff".to_string()),
31726            }],
31727            source_windows: vec![ExplorationSourceWindow {
31728                handle: "xwin-test".to_string(),
31729                file: "main.rs".to_string(),
31730                start: 1,
31731                end: 32,
31732                reason: "changed file".to_string(),
31733                expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
31734            }],
31735            worker_context: vec![ExplorationWorkerContext {
31736                handle: "xwrk-test".to_string(),
31737                target: "tasks/software/tsift.md".to_string(),
31738                summary: "do #kgnv".to_string(),
31739                expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
31740                    .to_string(),
31741            }],
31742            no_reread_guidance: "use windows".to_string(),
31743        };
31744
31745        let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
31746        assert_eq!(packet.source_windows[0].handle, "xwin-test");
31747
31748        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
31749        let source_handles = store.nodes_by_kind("source_handle").unwrap();
31750        assert_eq!(source_handles.len(), 1);
31751        assert_eq!(
31752            source_handles[0].properties.get("file"),
31753            Some(&"main.rs".to_string())
31754        );
31755        assert_eq!(
31756            store
31757                .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
31758                .unwrap()
31759                .len(),
31760            1
31761        );
31762        let worker_context = store.nodes_by_kind("worker_context").unwrap();
31763        assert_eq!(worker_context.len(), 1);
31764        assert_eq!(
31765            store
31766                .outgoing_edges("xwrk-test", Some("scopes_source"))
31767                .unwrap()
31768                .len(),
31769            1
31770        );
31771    }
31772
31773    #[test]
31774    fn context_pack_records_graph_orchestration_observability() {
31775        let dir = setup_traversal_project();
31776        init_git_repo(dir.path());
31777        let session = dir.path().join("tasks/software/tsift.md");
31778        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
31779
31780        let report = build_context_pack_report(
31781            &session,
31782            None,
31783            None,
31784            None,
31785            ResponseBudget::new(Some(4), Some(160)),
31786        )
31787        .unwrap();
31788
31789        assert_eq!(
31790            report.graph_orchestration.contract_version,
31791            CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
31792        );
31793        assert_eq!(
31794            report
31795                .graph_orchestration
31796                .projection_freshness
31797                .status
31798                .as_str(),
31799            "current"
31800        );
31801        assert!(!report.graph_orchestration.projection_hashes.is_empty());
31802        assert_eq!(report.graph_orchestration.readiness.status, "blocked");
31803        assert_eq!(
31804            report.graph_orchestration.readiness.reason,
31805            "summary_cache_empty"
31806        );
31807        assert!(report.graph_orchestration.readiness.fail_closed);
31808        assert!(
31809            report
31810                .graph_orchestration
31811                .readiness
31812                .next_commands
31813                .iter()
31814                .any(|command| command == "tsift summarize --extract ."),
31815            "{:?}",
31816            report.graph_orchestration.readiness.next_commands
31817        );
31818        assert!(
31819            report
31820                .graph_orchestration
31821                .evidence_packet_ids
31822                .iter()
31823                .all(|id| !id.starts_with("gevd-")),
31824            "evidence packet ids should be empty when readiness is blocked: {:?}",
31825            report.graph_orchestration.evidence_packet_ids
31826        );
31827        assert!(
31828            report
31829                .graph_orchestration
31830                .conflict_matrix_decisions
31831                .iter()
31832                .any(|decision| decision.contains("readiness blocked")),
31833            "conflict-matrix decisions should reference readiness block: {:?}",
31834            report.graph_orchestration.conflict_matrix_decisions
31835        );
31836        assert!(
31837            !report
31838                .graph_orchestration
31839                .follow_up_commands
31840                .iter()
31841                .any(|command| command.contains("conflict-matrix")),
31842            "conflict-matrix command should not appear when readiness is blocked: {:?}",
31843            report.graph_orchestration.follow_up_commands
31844        );
31845        assert!(
31846            report
31847                .graph_orchestration
31848                .follow_up_commands
31849                .iter()
31850                .any(|command| command == "tsift summarize --extract ."),
31851            "{:?}",
31852            report.graph_orchestration.follow_up_commands
31853        );
31854        assert!(
31855            !report
31856                .graph_orchestration
31857                .worker_ownership_blocks
31858                .is_empty()
31859        );
31860    }
31861
31862    #[test]
31863    fn convex_sync_report_chunks_upserts_and_tombstones() {
31864        let dir = setup_traversal_project();
31865        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
31866        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
31867        let mut snapshot = projection.to_convex_rows();
31868        snapshot.nodes.push(ConvexNodeRow {
31869            external_id: "stale-node".to_string(),
31870            kind: "backlog".to_string(),
31871            label: "stale".to_string(),
31872            properties: BTreeMap::new(),
31873            provenance: Vec::new(),
31874            freshness: None,
31875        });
31876        snapshot.edges.clear();
31877        snapshot.edges.push(ConvexEdgeRow {
31878            edge_key: "stale-edge".to_string(),
31879            from_external_id: "stale-node".to_string(),
31880            to_external_id: "stale-node".to_string(),
31881            kind: "mentions".to_string(),
31882            properties: BTreeMap::new(),
31883            provenance: Vec::new(),
31884            freshness: None,
31885        });
31886        let snapshot_path = dir.path().join("convex-snapshot.json");
31887        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31888
31889        let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
31890
31891        assert_eq!(report.freshness.status, "stale");
31892        assert!(report.freshness.fail_closed);
31893        assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
31894        assert!(
31895            report.edge_upserts.len() > 1,
31896            "snapshot without edges should upsert local edges"
31897        );
31898        assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
31899        assert_eq!(
31900            report.chunks.first().map(|chunk| chunk.operation.as_str()),
31901            Some("delete_edges"),
31902            "edge tombstones should be planned before node tombstones"
31903        );
31904        assert!(
31905            report
31906                .chunks
31907                .iter()
31908                .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
31909            "expected chunked edge upserts, got {:?}",
31910            report.chunks
31911        );
31912    }
31913
31914    #[test]
31915    fn convex_snapshot_validation_fails_closed_when_stale() {
31916        let dir = setup_traversal_project();
31917        build_traversal_graph(dir.path(), dir.path(), None).unwrap();
31918        let snapshot = ConvexProjectionRows::default();
31919        let snapshot_path = dir.path().join("empty-convex-snapshot.json");
31920        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31921
31922        let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
31923        assert!(
31924            err.to_string()
31925                .contains("Convex graph projection is not current"),
31926            "{err}"
31927        );
31928    }
31929
31930    #[test]
31931    fn convex_sync_report_marks_live_apply_mode_without_network() {
31932        let dir = setup_traversal_project();
31933        let report =
31934            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31935
31936        assert!(!report.dry_run);
31937        assert!(
31938            !report
31939                .diagnostics
31940                .iter()
31941                .any(|diagnostic| diagnostic.contains("dry-run only")),
31942            "apply-mode report should not claim dry-run diagnostics"
31943        );
31944        assert!(
31945            report
31946                .chunks
31947                .iter()
31948                .any(|chunk| chunk.operation == "upsert_nodes"),
31949            "live apply mode should still expose chunked idempotent operations"
31950        );
31951    }
31952
31953    #[test]
31954    fn convex_sync_apply_round_trips_with_http_backend() {
31955        use std::net::TcpListener;
31956        use std::sync::{Arc, Mutex};
31957
31958        let dir = setup_traversal_project();
31959        let report =
31960            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31961        let expected_chunks = report.chunks.len();
31962        assert!(expected_chunks > 0);
31963
31964        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
31965        let endpoint = format!("http://{}", listener.local_addr().unwrap());
31966        let operations = Arc::new(Mutex::new(Vec::<String>::new()));
31967        let server_operations = Arc::clone(&operations);
31968        let server = std::thread::spawn(move || {
31969            for _ in 0..expected_chunks {
31970                let (mut stream, _) = listener.accept().unwrap();
31971                let mut reader = BufReader::new(stream.try_clone().unwrap());
31972                let mut request_line = String::new();
31973                reader.read_line(&mut request_line).unwrap();
31974                assert!(request_line.starts_with("POST "));
31975
31976                let mut content_length = 0usize;
31977                loop {
31978                    let mut line = String::new();
31979                    reader.read_line(&mut line).unwrap();
31980                    if line == "\r\n" {
31981                        break;
31982                    }
31983                    if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
31984                        content_length = value.trim().parse().unwrap();
31985                    }
31986                }
31987
31988                let mut body = vec![0u8; content_length];
31989                reader.read_exact(&mut body).unwrap();
31990                let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
31991                server_operations
31992                    .lock()
31993                    .unwrap()
31994                    .push(request["operation"].as_str().unwrap().to_string());
31995
31996                let response = br#"{"status":"ok","message":"accepted"}"#;
31997                write!(
31998                    stream,
31999                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
32000                    response.len()
32001                )
32002                .unwrap();
32003                stream.write_all(response).unwrap();
32004            }
32005        });
32006
32007        cmd_convex_sync(
32008            ConvexSyncOptions {
32009                path: dir.path(),
32010                scope: None,
32011                snapshot: None,
32012                chunk_size: 100,
32013                remote_snapshot: false,
32014                apply: true,
32015                endpoint: Some(&endpoint),
32016                auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
32017            },
32018            OutputFormat {
32019                json_output: false,
32020                compact: true,
32021                pretty: false,
32022                terse: false,
32023                ultra_terse: false,
32024                schema: false,
32025                envelope: false,
32026            },
32027        )
32028        .unwrap();
32029        server.join().unwrap();
32030
32031        let operations = operations.lock().unwrap().clone();
32032        assert!(operations.contains(&"upsert_nodes".to_string()));
32033        assert!(operations.contains(&"upsert_edges".to_string()));
32034    }
32035
32036    #[test]
32037    fn context_pack_diff_preview_attaches_tag_ontology_refs() {
32038        let root = tempfile::tempdir().unwrap();
32039        fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
32040        fs::write(
32041            root.path().join(".naming/tags/alpha.md"),
32042            "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
32043        )
32044        .unwrap();
32045        let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
32046        let report = diff_digest::DiffDigestReport {
32047            root: root.path().display().to_string(),
32048            mode: diff_digest::DiffDigestMode::WorkingTree,
32049            revision: None,
32050            files_changed: 1,
32051            files_with_current_summaries: 1,
32052            symbols_touched: 1,
32053            call_edges_added: 0,
32054            call_edges_removed: 0,
32055            files: vec![diff_digest::DiffDigestFile {
32056                path: "src/lib.rs".to_string(),
32057                status: diff_digest::DiffDigestFileStatus::Modified,
32058                touched_symbols: vec!["alpha_helper".to_string()],
32059                summary_state: diff_digest::DiffDigestSummaryState::Current,
32060                current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
32061                    symbol: "alpha_helper".to_string(),
32062                    summary: "alpha helper summary".to_string(),
32063                }],
32064                added_call_edges: vec![],
32065                removed_call_edges: vec![],
32066                warnings: vec![],
32067            }],
32068        };
32069
32070        let preview = build_context_pack_diff_preview(
32071            &report,
32072            ResponseBudget::new(Some(1), Some(80)),
32073            Some(&ontology),
32074        );
32075
32076        let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
32077        assert!(symbol_ref.handle.starts_with("tont-"));
32078        assert_eq!(symbol_ref.tag, "alpha");
32079        assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
32080        assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
32081        assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
32082        assert_eq!(
32083            preview.files[0].summary_refs[0].ontology_refs[0].path,
32084            ".naming/tags/alpha.md"
32085        );
32086    }
32087
32088    #[test]
32089    fn context_pack_test_preview_limits_failure_groups() {
32090        let report = test_digest::TestDigestReport {
32091            root: "/repo".to_string(),
32092            runner: "cargo".to_string(),
32093            failures: 2,
32094            grouped_failures: 2,
32095            counts: test_digest::TestDigestCounts {
32096                passed: Some(8),
32097                failed: Some(2),
32098                skipped: Some(1),
32099            },
32100            failure_groups: vec![
32101                test_digest::TestDigestFailure {
32102                    tests: vec!["suite::alpha_failure".to_string()],
32103                    message: "assertion failed".to_string(),
32104                    path: Some("src/lib.rs".to_string()),
32105                    line: Some(42),
32106                    column: None,
32107                    occurrences: 1,
32108                    summary_state: test_digest::TestDigestSummaryState::Current,
32109                    current_summaries: vec![test_digest::TestDigestSummarySnippet {
32110                        symbol: "alpha_failure".to_string(),
32111                        summary: "failure summary for alpha test".to_string(),
32112                    }],
32113                },
32114                test_digest::TestDigestFailure {
32115                    tests: vec!["suite::beta_failure".to_string()],
32116                    message: "panic".to_string(),
32117                    path: Some("src/main.rs".to_string()),
32118                    line: Some(7),
32119                    column: None,
32120                    occurrences: 1,
32121                    summary_state: test_digest::TestDigestSummaryState::Missing,
32122                    current_summaries: vec![],
32123                },
32124            ],
32125            warnings: vec!["warning text".to_string()],
32126        };
32127
32128        let preview =
32129            build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32130
32131        assert!(preview.truncated);
32132        assert_eq!(preview.failure_groups.len(), 1);
32133        assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
32134        assert_eq!(preview.failure_groups[0].message, "assertion f...");
32135        assert!(
32136            preview.failure_groups[0].summary_refs[0]
32137                .handle
32138                .starts_with("ctsum-")
32139        );
32140        assert_eq!(
32141            preview.failure_groups[0].summary_refs[0].expand,
32142            "tsift summarize --file \"src/lib.rs\""
32143        );
32144        assert_eq!(preview.warnings, vec!["warning text"]);
32145    }
32146
32147    #[test]
32148    fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
32149        let dir = tempfile::tempdir().unwrap();
32150        let root = dir.path();
32151
32152        // Small log: no artifact attached, nothing written.
32153        let small_input = "Compiling serde v1.0.130\n";
32154        let mut small = log_digest::compute(root, small_input).unwrap();
32155        maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
32156        assert!(small.raw_log_artifact.is_none());
32157        assert!(!root.join(".tsift/artifacts").exists());
32158
32159        // Bulky log: artifact persisted with a stable handle and expand command.
32160        let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
32161        let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
32162        maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
32163        let artifact = bulky
32164            .raw_log_artifact
32165            .expect("artifact attached for bulky log");
32166        assert!(artifact.handle.starts_with("logdg-"));
32167        assert_eq!(artifact.bytes, bulky_input.len());
32168        assert!(artifact.expand.contains("tsift log-digest"));
32169        assert!(artifact.expand.contains("--input"));
32170        let persisted = root.join(&artifact.path);
32171        assert!(persisted.exists(), "artifact file written to {persisted:?}");
32172        assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
32173    }
32174
32175    #[test]
32176    fn context_pack_log_preview_limits_signals_and_refs() {
32177        let report = log_digest::LogDigestReport {
32178            root: "/repo".to_string(),
32179            total_lines: 12,
32180            non_empty_lines: 10,
32181            signal_groups: 2,
32182            error_signal_groups: 1,
32183            repeated_line_groups: 2,
32184            repeated_line_occurrences: 3,
32185            line_family_groups: 0,
32186            file_ref_groups: 2,
32187            symbol_ref_groups: 2,
32188            stack_groups: 1,
32189            signals: vec![
32190                log_digest::LogDigestSignal {
32191                    severity: "error".to_string(),
32192                    message: "src/lib.rs:42 boom".to_string(),
32193                    path: Some("src/lib.rs".to_string()),
32194                    line: Some(42),
32195                    column: None,
32196                    occurrences: 2,
32197                    summary_state: log_digest::LogDigestSummaryState::Current,
32198                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32199                        symbol: "alpha_helper".to_string(),
32200                        summary: "alpha helper cached log summary".to_string(),
32201                    }],
32202                },
32203                log_digest::LogDigestSignal {
32204                    severity: "warn".to_string(),
32205                    message: "slow path".to_string(),
32206                    path: None,
32207                    line: None,
32208                    column: None,
32209                    occurrences: 1,
32210                    summary_state: log_digest::LogDigestSummaryState::Unavailable,
32211                    current_summaries: vec![],
32212                },
32213            ],
32214            repeated_lines: vec![
32215                log_digest::LogDigestRepeatedLine {
32216                    line: "retrying work item alpha".to_string(),
32217                    occurrences: 3,
32218                },
32219                log_digest::LogDigestRepeatedLine {
32220                    line: "retrying work item beta".to_string(),
32221                    occurrences: 2,
32222                },
32223            ],
32224            line_families: vec![],
32225            file_refs: vec![
32226                log_digest::LogDigestFileRef {
32227                    path: "src/lib.rs".to_string(),
32228                    line: Some(42),
32229                    column: None,
32230                    occurrences: 2,
32231                    summary_state: log_digest::LogDigestSummaryState::Current,
32232                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32233                        symbol: "alpha_helper".to_string(),
32234                        summary: "alpha helper cached file summary".to_string(),
32235                    }],
32236                },
32237                log_digest::LogDigestFileRef {
32238                    path: "src/main.rs".to_string(),
32239                    line: Some(7),
32240                    column: None,
32241                    occurrences: 1,
32242                    summary_state: log_digest::LogDigestSummaryState::Missing,
32243                    current_summaries: vec![],
32244                },
32245            ],
32246            symbol_refs: vec![
32247                log_digest::LogDigestSymbolRef {
32248                    symbol: "alpha_helper".to_string(),
32249                    occurrences: 2,
32250                    summary_state: log_digest::LogDigestSummaryState::Current,
32251                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32252                        symbol: "alpha_helper".to_string(),
32253                        summary: "alpha helper cached symbol summary".to_string(),
32254                    }],
32255                },
32256                log_digest::LogDigestSymbolRef {
32257                    symbol: "beta_helper".to_string(),
32258                    occurrences: 1,
32259                    summary_state: log_digest::LogDigestSummaryState::Missing,
32260                    current_summaries: vec![],
32261                },
32262            ],
32263            stack_traces: vec![log_digest::LogDigestStackGroup {
32264                frames: vec!["frame one".to_string()],
32265                occurrences: 1,
32266            }],
32267            raw_log_artifact: None,
32268            warnings: vec!["warning text".to_string()],
32269        };
32270
32271        let preview =
32272            build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32273
32274        assert!(preview.truncated);
32275        assert_eq!(preview.signals.len(), 1);
32276        assert_eq!(preview.signals[0].message, "src/lib.rs:...");
32277        assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
32278        assert_eq!(preview.file_refs.len(), 1);
32279        assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
32280        assert!(
32281            preview.signals[0].summary_refs[0]
32282                .handle
32283                .starts_with("clsum-")
32284        );
32285        assert!(
32286            preview.file_refs[0].summary_refs[0]
32287                .handle
32288                .starts_with("clfsum-")
32289        );
32290        assert!(
32291            preview.symbol_refs[0].summary_refs[0]
32292                .handle
32293                .starts_with("clssum-")
32294        );
32295        assert_eq!(
32296            preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
32297            Some("alpha/helper")
32298        );
32299        assert_eq!(
32300            preview.symbol_refs[0].summary_refs[0].expand,
32301            "tsift summarize \"alpha_helper\""
32302        );
32303        assert_eq!(preview.warnings, vec!["warning text"]);
32304    }
32305
32306    #[test]
32307    fn cli_search_rejects_exact_with_strategy_flag() {
32308        let cli = try_parse_cli([
32309            "tsift",
32310            "search",
32311            "test",
32312            "--exact",
32313            "--strategy",
32314            "lexical",
32315        ]);
32316        assert!(cli.is_err());
32317    }
32318
32319    #[test]
32320    fn cli_search_autoindexes_by_default() {
32321        let cli = parse_cli(["tsift", "search", "test"]);
32322        match cli.command {
32323            Some(Commands::Search {
32324                autoindex,
32325                no_autoindex,
32326                ..
32327            }) => {
32328                assert!(!autoindex);
32329                assert!(!no_autoindex);
32330                assert!(autoindex || !no_autoindex);
32331            }
32332            _ => panic!("expected Search command"),
32333        }
32334    }
32335
32336    #[test]
32337    fn cli_local_model_status_accepts_json_and_no_probe() {
32338        let cli = parse_cli(["tsift", "local-model", "status", "--json", "--no-probe"]);
32339        match cli.command {
32340            Some(Commands::LocalModel {
32341                command: LocalModelCommand::Status { json, no_probe },
32342            }) => {
32343                assert!(json);
32344                assert!(no_probe);
32345            }
32346            _ => panic!("expected LocalModel status command"),
32347        }
32348    }
32349
32350    #[test]
32351    fn cli_local_model_unload_accepts_probe_and_strict_flags() {
32352        let cli = parse_cli([
32353            "tsift",
32354            "local-model",
32355            "unload",
32356            "--profile",
32357            "qwen3-32b-q4",
32358            "--pre-used-mib",
32359            "200",
32360            "--post-used-mib",
32361            "800",
32362            "--provider-pid",
32363            "42",
32364            "--strict",
32365            "--json",
32366        ]);
32367        match cli.command {
32368            Some(Commands::LocalModel {
32369                command:
32370                    LocalModelCommand::Unload {
32371                        profile,
32372                        provider_pid,
32373                        pre_used_mib,
32374                        post_used_mib,
32375                        strict,
32376                        json,
32377                        ..
32378                    },
32379            }) => {
32380                assert_eq!(profile, "qwen3-32b-q4");
32381                assert_eq!(provider_pid, Some(42));
32382                assert_eq!(pre_used_mib, Some(200));
32383                assert_eq!(post_used_mib, Some(800));
32384                assert!(strict);
32385                assert!(json);
32386            }
32387            _ => panic!("expected LocalModel unload command"),
32388        }
32389    }
32390
32391    #[test]
32392    fn cli_local_model_swap_parses_flags() {
32393        let cli = parse_cli([
32394            "tsift",
32395            "local-model",
32396            "swap",
32397            "--from",
32398            "qwen3-32b-q4",
32399            "--to",
32400            "qwen3-embedding-0.6b",
32401            "--provider-pid",
32402            "42",
32403            "--pre-used-mib",
32404            "200",
32405            "--post-used-mib",
32406            "180",
32407            "--strict",
32408            "--json",
32409        ]);
32410        match cli.command {
32411            Some(Commands::LocalModel {
32412                command:
32413                    LocalModelCommand::Swap {
32414                        from,
32415                        to,
32416                        provider_pid,
32417                        pre_used_mib,
32418                        post_used_mib,
32419                        strict,
32420                        json,
32421                        ..
32422                    },
32423            }) => {
32424                assert_eq!(from, "qwen3-32b-q4");
32425                assert_eq!(to, "qwen3-embedding-0.6b");
32426                assert_eq!(provider_pid, Some(42));
32427                assert_eq!(pre_used_mib, Some(200));
32428                assert_eq!(post_used_mib, Some(180));
32429                assert!(strict);
32430                assert!(json);
32431            }
32432            _ => panic!("expected LocalModel swap command"),
32433        }
32434    }
32435
32436    #[test]
32437    fn cli_local_model_resolve_parses_flags() {
32438        use cli::ResolveRole;
32439        let cli = parse_cli([
32440            "tsift",
32441            "local-model",
32442            "resolve",
32443            "--profile",
32444            "hash",
32445            "--role",
32446            "embed",
32447            "--no-probe",
32448            "--json",
32449        ]);
32450        match cli.command {
32451            Some(Commands::LocalModel {
32452                command:
32453                    LocalModelCommand::Resolve {
32454                        profile,
32455                        role,
32456                        no_probe,
32457                        json,
32458                    },
32459            }) => {
32460                assert_eq!(profile.as_deref(), Some("hash"));
32461                assert_eq!(role, ResolveRole::Embed);
32462                assert!(no_probe);
32463                assert!(json);
32464            }
32465            _ => panic!("expected LocalModel resolve command"),
32466        }
32467    }
32468
32469    #[test]
32470    fn cli_semantic_command_accepts_profile_flag() {
32471        let cli = parse_cli([
32472            "tsift",
32473            "semantic",
32474            "auth",
32475            "--profile",
32476            "qwen3-embedding-0.6b",
32477            "--json",
32478        ]);
32479        match cli.command {
32480            Some(Commands::Semantic { profile, query, .. }) => {
32481                assert_eq!(query, "auth");
32482                assert_eq!(profile.as_deref(), Some("qwen3-embedding-0.6b"));
32483            }
32484            _ => panic!("expected Semantic command"),
32485        }
32486    }
32487
32488    #[test]
32489    fn cli_summarize_command_accepts_profile_flag() {
32490        let cli = parse_cli([
32491            "tsift",
32492            "summarize",
32493            "--extract",
32494            "src",
32495            "--profile",
32496            "hash",
32497            "--json",
32498        ]);
32499        match cli.command {
32500            Some(Commands::Summarize {
32501                extract,
32502                profile,
32503                json,
32504                ..
32505            }) => {
32506                assert_eq!(extract.as_deref(), Some(std::path::Path::new("src")));
32507                assert_eq!(profile.as_deref(), Some("hash"));
32508                assert!(json);
32509            }
32510            _ => panic!("expected Summarize command"),
32511        }
32512    }
32513
32514    #[test]
32515    fn cli_local_model_lease_acquire_parses_flags() {
32516        let cli = parse_cli([
32517            "tsift",
32518            "local-model",
32519            "lease",
32520            "acquire",
32521            "--profile",
32522            "qwen3-32b-q4",
32523            "--holder-pid",
32524            "4242",
32525            "--holder-command",
32526            "corky",
32527            "--idle-ttl-seconds",
32528            "120",
32529            "--vram-baseline-mib",
32530            "200",
32531            "--lease-file",
32532            "/tmp/tsift-lease.json",
32533            "--strict",
32534            "--json",
32535        ]);
32536        match cli.command {
32537            Some(Commands::LocalModel {
32538                command:
32539                    LocalModelCommand::Lease {
32540                        command:
32541                            LeaseCommand::Acquire {
32542                                profile,
32543                                holder_pid,
32544                                holder_command,
32545                                idle_ttl_seconds,
32546                                vram_baseline_mib,
32547                                lease_file,
32548                                strict,
32549                                json,
32550                                ..
32551                            },
32552                    },
32553            }) => {
32554                assert_eq!(profile, "qwen3-32b-q4");
32555                assert_eq!(holder_pid, Some(4242));
32556                assert_eq!(holder_command, "corky");
32557                assert_eq!(idle_ttl_seconds, 120);
32558                assert_eq!(vram_baseline_mib, Some(200));
32559                assert_eq!(lease_file, Some(PathBuf::from("/tmp/tsift-lease.json")));
32560                assert!(strict);
32561                assert!(json);
32562            }
32563            _ => panic!("expected LocalModel lease acquire command"),
32564        }
32565    }
32566
32567    #[test]
32568    fn cli_local_model_lease_release_parses_flags() {
32569        let cli = parse_cli([
32570            "tsift",
32571            "local-model",
32572            "lease",
32573            "release",
32574            "--profile",
32575            "qwen3-embedding-0.6b",
32576            "--holder-pid",
32577            "999",
32578            "--json",
32579        ]);
32580        match cli.command {
32581            Some(Commands::LocalModel {
32582                command:
32583                    LocalModelCommand::Lease {
32584                        command:
32585                            LeaseCommand::Release {
32586                                profile,
32587                                holder_pid,
32588                                json,
32589                                ..
32590                            },
32591                    },
32592            }) => {
32593                assert_eq!(profile, "qwen3-embedding-0.6b");
32594                assert_eq!(holder_pid, Some(999));
32595                assert!(json);
32596            }
32597            _ => panic!("expected LocalModel lease release command"),
32598        }
32599    }
32600
32601    #[test]
32602    fn cli_local_model_lease_show_parses_flags() {
32603        let cli = parse_cli([
32604            "tsift",
32605            "local-model",
32606            "lease",
32607            "show",
32608            "--include-stale",
32609            "--json",
32610        ]);
32611        match cli.command {
32612            Some(Commands::LocalModel {
32613                command:
32614                    LocalModelCommand::Lease {
32615                        command:
32616                            LeaseCommand::Show {
32617                                include_stale,
32618                                json,
32619                                ..
32620                            },
32621                    },
32622            }) => {
32623                assert!(include_stale);
32624                assert!(json);
32625            }
32626            _ => panic!("expected LocalModel lease show command"),
32627        }
32628    }
32629
32630    #[test]
32631    fn cli_search_accepts_no_autoindex_flag() {
32632        let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
32633        match cli.command {
32634            Some(Commands::Search {
32635                autoindex,
32636                no_autoindex,
32637                ..
32638            }) => {
32639                assert!(!autoindex);
32640                assert!(no_autoindex);
32641            }
32642            _ => panic!("expected Search command"),
32643        }
32644    }
32645
32646    #[test]
32647    fn cli_search_rejects_conflicting_autoindex_flags() {
32648        let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
32649        assert!(cli.is_err());
32650    }
32651
32652    // --- relativize paths ---
32653
32654    #[test]
32655    fn cli_accepts_global_absolute_flag() {
32656        let cli = parse_cli(["tsift", "--absolute", "status"]);
32657        assert!(cli.absolute);
32658        assert!(matches!(cli.command, Some(Commands::Status { .. })));
32659    }
32660
32661    #[test]
32662    fn cli_accepts_global_tabular_flag() {
32663        let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
32664        assert!(cli.tabular);
32665        assert!(matches!(cli.command, Some(Commands::Search { .. })));
32666    }
32667
32668    #[test]
32669    fn cli_tabular_with_graph() {
32670        let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
32671        assert!(cli.tabular);
32672        assert!(matches!(cli.command, Some(Commands::Graph { .. })));
32673    }
32674
32675    #[test]
32676    fn cli_tabular_with_communities() {
32677        let cli = parse_cli(["tsift", "--tabular", "communities"]);
32678        assert!(cli.tabular);
32679        assert!(matches!(cli.command, Some(Commands::Communities { .. })));
32680    }
32681
32682    #[test]
32683    fn cli_tabular_with_explain() {
32684        let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
32685        assert!(cli.tabular);
32686        assert!(matches!(cli.command, Some(Commands::Explain { .. })));
32687    }
32688
32689    #[test]
32690    fn cli_traverse_accepts_path_target_and_html_format() {
32691        let cli = parse_cli([
32692            "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
32693        ]);
32694        match cli.command {
32695            Some(Commands::Traverse {
32696                node,
32697                to,
32698                path,
32699                format,
32700                ..
32701            }) => {
32702                assert_eq!(node.as_deref(), Some("#kgnv"));
32703                assert_eq!(to.as_deref(), Some("main"));
32704                assert_eq!(path, PathBuf::from("."));
32705                assert_eq!(format, TraverseFormat::Html);
32706            }
32707            _ => panic!("expected Traverse command"),
32708        }
32709    }
32710
32711    #[test]
32712    fn cli_parses_semantic_related_command() {
32713        let cli = parse_cli([
32714            "tsift",
32715            "semantic",
32716            "graph navigation",
32717            "--path",
32718            ".",
32719            "--kind",
32720            "all",
32721            "--limit",
32722            "3",
32723            "--json",
32724        ]);
32725        match cli.command {
32726            Some(Commands::Semantic {
32727                query,
32728                path,
32729                kind,
32730                limit,
32731                json,
32732                ..
32733            }) => {
32734                assert_eq!(query, "graph navigation");
32735                assert_eq!(path, PathBuf::from("."));
32736                assert_eq!(kind, SemanticRelatedKind::All);
32737                assert_eq!(limit, 3);
32738                assert!(json);
32739            }
32740            _ => panic!("expected Semantic command"),
32741        }
32742    }
32743
32744    #[test]
32745    fn cli_parses_convex_sync_command() {
32746        let cli = parse_cli([
32747            "tsift",
32748            "convex-sync",
32749            ".",
32750            "--snapshot",
32751            "rows.json",
32752            "--chunk-size",
32753            "25",
32754            "--json",
32755        ]);
32756        match cli.command {
32757            Some(Commands::ConvexSync {
32758                path,
32759                snapshot,
32760                chunk_size,
32761                json,
32762                ..
32763            }) => {
32764                assert_eq!(path, PathBuf::from("."));
32765                assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
32766                assert_eq!(chunk_size, 25);
32767                assert!(json);
32768            }
32769            _ => panic!("expected ConvexSync command"),
32770        }
32771    }
32772
32773    #[test]
32774    fn cli_parses_convex_sync_live_flags() {
32775        let cli = parse_cli([
32776            "tsift",
32777            "convex-sync",
32778            ".",
32779            "--remote-snapshot",
32780            "--apply",
32781            "--endpoint",
32782            "https://example.test/convex-graph",
32783            "--auth-token-env",
32784            "TSIFT_TEST_TOKEN",
32785        ]);
32786        match cli.command {
32787            Some(Commands::ConvexSync {
32788                remote_snapshot,
32789                apply,
32790                endpoint,
32791                auth_token_env,
32792                ..
32793            }) => {
32794                assert!(remote_snapshot);
32795                assert!(apply);
32796                assert_eq!(
32797                    endpoint.as_deref(),
32798                    Some("https://example.test/convex-graph")
32799                );
32800                assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
32801            }
32802            _ => panic!("expected ConvexSync command"),
32803        }
32804    }
32805
32806    #[test]
32807    fn cli_parses_graph_db_query() {
32808        let cli = parse_cli([
32809            "tsift",
32810            "graph-db",
32811            "--backend",
32812            "convex-snapshot",
32813            "--convex-snapshot",
32814            "rows.json",
32815            "--json",
32816            "neighborhood",
32817            "gbak-kgnv",
32818            "--depth",
32819            "2",
32820            "--edge-kind",
32821            "mentions",
32822            "--property",
32823            "path=tasks/software/tsift.md",
32824            "--cursor",
32825            "gbak-old",
32826            "--limit",
32827            "10",
32828        ]);
32829        match cli.command {
32830            Some(Commands::GraphDb {
32831                backend,
32832                convex_snapshot,
32833                json,
32834                query,
32835                ..
32836            }) => {
32837                assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
32838                assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
32839                assert!(json);
32840                match query {
32841                    GraphDbQuery::Neighborhood {
32842                        id,
32843                        depth,
32844                        edge_kind,
32845                        cursor,
32846                        limit,
32847                        property_filters,
32848                    } => {
32849                        assert_eq!(id, "gbak-kgnv");
32850                        assert_eq!(depth, 2);
32851                        assert_eq!(edge_kind.as_deref(), Some("mentions"));
32852                        assert_eq!(cursor.as_deref(), Some("gbak-old"));
32853                        assert_eq!(limit, Some(10));
32854                        assert_eq!(
32855                            property_filters,
32856                            vec!["path=tasks/software/tsift.md".to_string()]
32857                        );
32858                    }
32859                    _ => panic!("expected graph-db neighborhood query"),
32860                }
32861            }
32862            _ => panic!("expected GraphDb command"),
32863        }
32864    }
32865
32866    #[test]
32867    fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
32868        let cli = parse_cli([
32869            "tsift",
32870            "graph-db",
32871            "--json",
32872            "backend-eval",
32873            "--candidate",
32874            "surrealdb",
32875            "--target",
32876            "gval",
32877            "--full-projection",
32878        ]);
32879        match cli.command {
32880            Some(Commands::GraphDb { json, query, .. }) => {
32881                assert!(json);
32882                match query {
32883                    GraphDbQuery::BackendEval {
32884                        candidates,
32885                        targets,
32886                        full_projection,
32887                    } => {
32888                        assert_eq!(candidates, vec!["surrealdb".to_string()]);
32889                        assert_eq!(targets, vec!["gval".to_string()]);
32890                        assert!(full_projection);
32891                    }
32892                    _ => panic!("expected graph-db backend-eval query"),
32893                }
32894            }
32895            _ => panic!("expected GraphDb command"),
32896        }
32897    }
32898
32899    #[test]
32900    fn cli_parses_graph_db_tokensave_backend() {
32901        let cli = parse_cli([
32902            "tsift",
32903            "graph-db",
32904            "--backend",
32905            "tokensave",
32906            "--json",
32907            "node",
32908            "fn:main",
32909        ]);
32910        match cli.command {
32911            Some(Commands::GraphDb {
32912                backend,
32913                json,
32914                query,
32915                ..
32916            }) => {
32917                assert_eq!(backend, GraphDbBackend::Tokensave);
32918                assert!(json);
32919                match query {
32920                    GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
32921                    _ => panic!("expected graph-db node query"),
32922                }
32923            }
32924            _ => panic!("expected GraphDb command"),
32925        }
32926    }
32927
32928    #[test]
32929    fn cli_parses_analyze_command() {
32930        let cli = parse_cli([
32931            "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
32932            "--limit", "7", "--json",
32933        ]);
32934        match cli.command {
32935            Some(Commands::Analyze {
32936                path,
32937                scope,
32938                entry_points,
32939                limit,
32940                json,
32941            }) => {
32942                assert_eq!(path, PathBuf::from("."));
32943                assert_eq!(scope.as_deref(), Some("core"));
32944                assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
32945                assert_eq!(limit, 7);
32946                assert!(json);
32947            }
32948            _ => panic!("expected Analyze command"),
32949        }
32950    }
32951
32952    #[test]
32953    fn cli_parses_graph_db_related_query() {
32954        let cli = parse_cli([
32955            "tsift",
32956            "graph-db",
32957            "--json",
32958            "related",
32959            "voice avatar memory retrieval",
32960            "--kind",
32961            "all",
32962            "--depth",
32963            "3",
32964            "--seed-limit",
32965            "4",
32966            "--limit",
32967            "12",
32968        ]);
32969        match cli.command {
32970            Some(Commands::GraphDb { json, query, .. }) => {
32971                assert!(json);
32972                match query {
32973                    GraphDbQuery::Related {
32974                        query,
32975                        kind,
32976                        depth,
32977                        seed_limit,
32978                        limit,
32979                    } => {
32980                        assert_eq!(query, "voice avatar memory retrieval");
32981                        assert_eq!(kind, SemanticRelatedKind::All);
32982                        assert_eq!(depth, 3);
32983                        assert_eq!(seed_limit, 4);
32984                        assert_eq!(limit, 12);
32985                    }
32986                    _ => panic!("expected graph-db related query"),
32987                }
32988            }
32989            _ => panic!("expected GraphDb command"),
32990        }
32991    }
32992
32993    #[test]
32994    fn cli_parses_graph_db_compact_query() {
32995        let cli = parse_cli([
32996            "tsift",
32997            "graph-db",
32998            "--path",
32999            ".",
33000            "compact",
33001            "--apply",
33002            "--prune-tombstones",
33003            "--confirmed-convex-reconciled",
33004        ]);
33005        match cli.command {
33006            Some(Commands::GraphDb { query, .. }) => match query {
33007                GraphDbQuery::Compact {
33008                    apply,
33009                    prune_tombstones,
33010                    confirmed_convex_reconciled,
33011                } => {
33012                    assert!(apply);
33013                    assert!(prune_tombstones);
33014                    assert!(confirmed_convex_reconciled);
33015                }
33016                _ => panic!("expected graph-db compact query"),
33017            },
33018            _ => panic!("expected GraphDb command"),
33019        }
33020    }
33021
33022    #[test]
33023    fn cli_parses_graph_db_snapshot_queries() {
33024        let export_cli = parse_cli([
33025            "tsift",
33026            "graph-db",
33027            "--json",
33028            "snapshot-export",
33029            "graph.db.gz",
33030            "--force",
33031        ]);
33032        match export_cli.command {
33033            Some(Commands::GraphDb { json, query, .. }) => {
33034                assert!(json);
33035                match query {
33036                    GraphDbQuery::SnapshotExport { output, force } => {
33037                        assert_eq!(output, PathBuf::from("graph.db.gz"));
33038                        assert!(force);
33039                    }
33040                    _ => panic!("expected graph-db snapshot-export query"),
33041                }
33042            }
33043            _ => panic!("expected GraphDb command"),
33044        }
33045
33046        let import_cli = parse_cli([
33047            "tsift",
33048            "graph-db",
33049            "snapshot-import",
33050            "graph.db.gz",
33051            "--replace",
33052        ]);
33053        match import_cli.command {
33054            Some(Commands::GraphDb { query, .. }) => match query {
33055                GraphDbQuery::SnapshotImport { artifact, replace } => {
33056                    assert_eq!(artifact, PathBuf::from("graph.db.gz"));
33057                    assert!(replace);
33058                }
33059                _ => panic!("expected graph-db snapshot-import query"),
33060            },
33061            _ => panic!("expected GraphDb command"),
33062        }
33063    }
33064
33065    #[test]
33066    fn cli_parses_impact_command() {
33067        let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
33068        match cli.command {
33069            Some(Commands::Impact {
33070                path,
33071                cached,
33072                limit,
33073                ..
33074            }) => {
33075                assert_eq!(path, PathBuf::from("."));
33076                assert!(cached);
33077                assert_eq!(limit, 5);
33078            }
33079            _ => panic!("expected Impact command"),
33080        }
33081    }
33082
33083    #[test]
33084    fn cli_parses_conflict_matrix_command() {
33085        let cli = parse_cli([
33086            "tsift",
33087            "conflict-matrix",
33088            "--path",
33089            "tasks/software/tsift.md",
33090            "--depth",
33091            "4",
33092            "--limit",
33093            "12",
33094            "--impact-limit",
33095            "6",
33096            "--json",
33097            "pwcm",
33098            "#g6kf",
33099        ]);
33100        match cli.command {
33101            Some(Commands::ConflictMatrix {
33102                targets,
33103                path,
33104                depth,
33105                limit,
33106                impact_limit,
33107                json,
33108                ..
33109            }) => {
33110                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33111                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33112                assert_eq!(depth, 4);
33113                assert_eq!(limit, 12);
33114                assert_eq!(impact_limit, 6);
33115                assert!(json);
33116            }
33117            _ => panic!("expected ConflictMatrix command"),
33118        }
33119    }
33120
33121    #[test]
33122    fn cli_parses_dispatch_trace_command() {
33123        let cli = parse_cli([
33124            "tsift",
33125            "dispatch-trace",
33126            "--path",
33127            "tasks/software/tsift.md",
33128            "--format",
33129            "html",
33130            "--depth",
33131            "4",
33132            "pwcm",
33133            "#g6kf",
33134        ]);
33135        match cli.command {
33136            Some(Commands::DispatchTrace {
33137                targets,
33138                path,
33139                format,
33140                depth,
33141                ..
33142            }) => {
33143                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33144                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33145                assert_eq!(format, DispatchTraceFormat::Html);
33146                assert_eq!(depth, 4);
33147            }
33148            _ => panic!("expected DispatchTrace command"),
33149        }
33150    }
33151
33152    #[test]
33153    fn cli_parses_dependency_dag_command() {
33154        let cli = parse_cli([
33155            "tsift",
33156            "dependency-dag",
33157            "--path",
33158            "tasks/software/tsift.md",
33159            "--depth",
33160            "5",
33161            "--limit",
33162            "20",
33163            "--json",
33164            "alpha",
33165            "#beta",
33166        ]);
33167        match cli.command {
33168            Some(Commands::DependencyDag {
33169                targets,
33170                path,
33171                depth,
33172                limit,
33173                json,
33174                ..
33175            }) => {
33176                assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
33177                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33178                assert_eq!(depth, 5);
33179                assert_eq!(limit, 20);
33180                assert!(json);
33181            }
33182            _ => panic!("expected DependencyDag command"),
33183        }
33184    }
33185
33186    #[test]
33187    fn relativize_strips_root_prefix() {
33188        let root = std::path::Path::new("/home/user/project");
33189        assert_eq!(
33190            relativize("/home/user/project/src/main.rs", root),
33191            "src/main.rs"
33192        );
33193    }
33194
33195    #[test]
33196    fn relativize_leaves_non_matching_path() {
33197        let root = std::path::Path::new("/home/user/project");
33198        assert_eq!(
33199            relativize("/other/path/file.rs", root),
33200            "/other/path/file.rs"
33201        );
33202    }
33203
33204    #[test]
33205    fn relativize_leaves_already_relative() {
33206        let root = std::path::Path::new("/home/user/project");
33207        assert_eq!(relativize("src/main.rs", root), "src/main.rs");
33208    }
33209
33210    #[test]
33211    fn relativize_pathbuf_strips_prefix() {
33212        let root = std::path::Path::new("/home/user/project");
33213        let path = std::path::Path::new("/home/user/project/src/lib.rs");
33214        assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
33215    }
33216
33217    #[test]
33218    fn relativize_edges_strips_caller_file() {
33219        let root = std::path::Path::new("/tmp/proj");
33220        let mut edges = vec![index::StoredEdge {
33221            caller_file: "/tmp/proj/src/main.rs".to_string(),
33222            caller_name: "main".to_string(),
33223            caller_line: 1,
33224            callee_name: "helper".to_string(),
33225            call_site_line: 5,
33226            tagpath_handle: None,
33227        }];
33228        relativize_edges(&mut edges, root);
33229        assert_eq!(edges[0].caller_file, "src/main.rs");
33230    }
33231
33232    #[test]
33233    fn relativize_json_paths_strips_known_keys() {
33234        let root = std::path::Path::new("/tmp/proj");
33235        let mut val = serde_json::json!({
33236            "file": "/tmp/proj/src/main.rs",
33237            "path": "/tmp/proj/test.rs",
33238            "name": "/tmp/proj/not-a-path",
33239            "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
33240        });
33241        relativize_json_paths(&mut val, root);
33242        assert_eq!(val["file"], "src/main.rs");
33243        assert_eq!(val["path"], "test.rs");
33244        assert_eq!(val["name"], "/tmp/proj/not-a-path");
33245        assert_eq!(val["hits"][0]["path"], "nested.rs");
33246    }
33247
33248    // --- limit caps ---
33249
33250    #[test]
33251    fn cli_graph_accepts_limit_flag() {
33252        let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
33253        match cli.command {
33254            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
33255            _ => panic!("expected Graph command"),
33256        }
33257    }
33258
33259    #[test]
33260    fn cli_graph_default_limit_is_20() {
33261        let cli = parse_cli(["tsift", "graph", "main"]);
33262        match cli.command {
33263            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
33264            _ => panic!("expected Graph command"),
33265        }
33266    }
33267
33268    #[test]
33269    fn cli_communities_accepts_limit_flag() {
33270        let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
33271        match cli.command {
33272            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
33273            _ => panic!("expected Communities command"),
33274        }
33275    }
33276
33277    #[test]
33278    fn cli_communities_default_limit_is_10() {
33279        let cli = parse_cli(["tsift", "communities"]);
33280        match cli.command {
33281            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
33282            _ => panic!("expected Communities command"),
33283        }
33284    }
33285
33286    #[test]
33287    fn cli_explain_accepts_limit_flag() {
33288        let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
33289        match cli.command {
33290            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
33291            _ => panic!("expected Explain command"),
33292        }
33293    }
33294
33295    #[test]
33296    fn cli_explain_default_limit_is_15() {
33297        let cli = parse_cli(["tsift", "explain", "main"]);
33298        match cli.command {
33299            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
33300            _ => panic!("expected Explain command"),
33301        }
33302    }
33303
33304    #[test]
33305    fn cli_limit_zero_means_unlimited() {
33306        let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
33307        match cli.command {
33308            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
33309            _ => panic!("expected Graph command"),
33310        }
33311    }
33312
33313    #[test]
33314    fn graph_cmd_limit_runs_ok() {
33315        let dir = setup_graph_index();
33316        let result = cmd_graph(
33317            "main",
33318            dir.path(),
33319            false,
33320            false,
33321            None,
33322            1,
33323            false,
33324            false,
33325            false,
33326            false,
33327            false,
33328            false,
33329            false,
33330            TagpathSearchOpts::default(),
33331        );
33332        assert!(result.is_ok());
33333    }
33334
33335    #[test]
33336    fn graph_cmd_unlimited_runs_ok() {
33337        let dir = setup_graph_index();
33338        let result = cmd_graph(
33339            "main",
33340            dir.path(),
33341            false,
33342            false,
33343            None,
33344            0,
33345            false,
33346            false,
33347            false,
33348            false,
33349            false,
33350            false,
33351            false,
33352            TagpathSearchOpts::default(),
33353        );
33354        assert!(result.is_ok());
33355    }
33356
33357    #[test]
33358    fn graph_cmd_tabular_runs_ok() {
33359        let dir = setup_graph_index();
33360        let result = cmd_graph(
33361            "main",
33362            dir.path(),
33363            false,
33364            false,
33365            None,
33366            20,
33367            false,
33368            false,
33369            false,
33370            false,
33371            false,
33372            true,
33373            false,
33374            TagpathSearchOpts::default(),
33375        );
33376        assert!(result.is_ok());
33377    }
33378
33379    #[test]
33380    fn communities_cmd_tabular_runs_ok() {
33381        let dir = setup_graph_index();
33382        let result = cmd_communities(
33383            dir.path(),
33384            None,
33385            1,
33386            10,
33387            false,
33388            false,
33389            false,
33390            false,
33391            true,
33392            false,
33393            TagpathSearchOpts::default(),
33394        );
33395        assert!(result.is_ok());
33396    }
33397
33398    #[test]
33399    fn explain_cmd_tabular_runs_ok() {
33400        let dir = setup_graph_index();
33401        let result = cmd_explain(
33402            "main",
33403            dir.path(),
33404            None,
33405            15,
33406            false,
33407            false,
33408            false,
33409            false,
33410            false,
33411            true,
33412            false,
33413            false,
33414        );
33415        assert!(result.is_ok());
33416    }
33417
33418    #[test]
33419    fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
33420        // #gdbcacheprove: .agent-doc runtime markdown (snapshots, baselines, archives,
33421        // session docs, runtime logs) must not contribute to the source watermark, or
33422        // every agent-doc cycle would invalidate the graph-db backend-eval cache and
33423        // force a full rebuild on the next run.
33424        let cases = [
33425            ".agent-doc",
33426            ".agent-doc/snapshots/abc.md",
33427            ".agent-doc/baselines/abc.md",
33428            ".agent-doc/archives/2026.md",
33429            ".agent-doc/runtime/run.jsonl",
33430            "src/foo/.agent-doc",
33431            "src/foo/.agent-doc/snapshots/x.md",
33432            "./.agent-doc/snapshots/x.md",
33433        ];
33434        for path in cases {
33435            assert!(
33436                traversal_relative_path_is_generated_artifact(path),
33437                "expected `{path}` to be excluded from source watermark"
33438            );
33439        }
33440        // Real source paths must NOT be excluded.
33441        for path in [
33442            "src/main.rs",
33443            "tests/perf_gate.rs",
33444            "fixtures/x.json",
33445            "agent-doc/src/lib.rs", // sibling dir without the leading dot
33446            "src/.agent-doc-helper.rs",
33447        ] {
33448            assert!(
33449                !traversal_relative_path_is_generated_artifact(path),
33450                "expected `{path}` to be included in source watermark"
33451            );
33452        }
33453    }
33454
33455    #[test]
33456    fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
33457        // #cachelookupshift: the conflict-matrix preparation cache key hashes
33458        // file_state snapshot rows + every markdown file under the root. Any
33459        // .tsift/, target/, or .agent-doc/ path slipping past the filter would
33460        // shift the watermark every run because those directories mutate as a
33461        // side effect of running tsift itself. This test locks the artifact
33462        // filter against regressions for each prefix variant
33463        // (bare, root-anchored, nested, and './' leading).
33464        let cases = [
33465            ".tsift",
33466            ".tsift/index.db",
33467            ".tsift/indexes/foo/index.db",
33468            ".tsift/conflict-matrix-cache/inputs/abc.json",
33469            ".tsift/summaries.db",
33470            "src/foo/.tsift",
33471            "src/foo/.tsift/graph.db",
33472            "./.tsift/index.db",
33473            "target",
33474            "target/debug/build/x",
33475            "target/release/tsift",
33476            "src/foo/target/debug/x",
33477            "./target/release/x",
33478        ];
33479        for path in cases {
33480            assert!(
33481                traversal_relative_path_is_generated_artifact(path),
33482                "expected `{path}` to be excluded from source watermark"
33483            );
33484        }
33485        // Look-alike paths must NOT be excluded — only true artifact dirs.
33486        for path in [
33487            "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
33488            "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
33489            "src/tsift-extras/lib.rs",
33490            "tsift/README.md",
33491            "src/targeting.rs",
33492            "src/.tsiftrc",
33493            "src/agent-doc-helper.rs",
33494        ] {
33495            assert!(
33496                !traversal_relative_path_is_generated_artifact(path),
33497                "expected `{path}` to be included in source watermark"
33498            );
33499        }
33500    }
33501
33502    #[test]
33503    fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
33504        // #cachelookupshift: the conflict-matrix preparation cache only hits
33505        // when traversal_source_watermark returns the same hash for two
33506        // consecutive calls on identical source state. Lock that invariant so
33507        // a future change that folds wall-clock time, a directory mtime, or
33508        // any other non-content input into the hash trips this test before
33509        // regressing the preparation_cache_lookup hit rate. We exercise the
33510        // session_only=true path with a hinted markdown file so the test does
33511        // not need a full index DB to drive the index-snapshot branch.
33512        let dir = tempfile::tempdir().unwrap();
33513        let root = dir.path();
33514        std::fs::create_dir_all(root.join("src")).unwrap();
33515        std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
33516        let hint = root.join("README.md");
33517        std::fs::write(&hint, "# stable\n").unwrap();
33518        // Add a generated-artifact directory that must NOT affect the watermark.
33519        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33520        std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
33521        std::fs::create_dir_all(root.join("target/debug")).unwrap();
33522        std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
33523
33524        let first = traversal_source_watermark(root, &hint, None, true)
33525            .expect("first watermark call must succeed")
33526            .expect("first watermark must produce a hash for hinted markdown");
33527        let second = traversal_source_watermark(root, &hint, None, true)
33528            .expect("second watermark call must succeed")
33529            .expect("second watermark must produce a hash for hinted markdown");
33530        assert_eq!(
33531            first, second,
33532            "watermark must be identical across back-to-back invocations on a quiescent root"
33533        );
33534
33535        // Mutating a generated-artifact file must NOT shift the hash.
33536        std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
33537        std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
33538        let third = traversal_source_watermark(root, &hint, None, true)
33539            .expect("third watermark call must succeed")
33540            .expect("third watermark must produce a hash for hinted markdown");
33541        assert_eq!(
33542            first, third,
33543            "watermark must ignore mutations under .tsift/ and target/"
33544        );
33545
33546        // Mutating the hinted markdown file MUST shift the hash so the
33547        // preparation cache invalidates correctly when user state changes.
33548        // Sleep briefly to push the file mtime past the original even on
33549        // coarse-resolution filesystems.
33550        std::thread::sleep(std::time::Duration::from_millis(20));
33551        std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
33552        let fourth = traversal_source_watermark(root, &hint, None, true)
33553            .expect("fourth watermark call must succeed")
33554            .expect("fourth watermark must produce a hash for hinted markdown");
33555        assert_ne!(
33556            first, fourth,
33557            "watermark must invalidate when the hinted markdown file changes"
33558        );
33559    }
33560
33561    #[test]
33562    fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
33563        // #gcachemiss: full-projection cache keys must not miss just because the
33564        // SQLite summary cache file header or mtime churned. Only the semantic rows
33565        // that feed traversal projection should participate in the source watermark.
33566        let dir = tempfile::tempdir().unwrap();
33567        let root = dir.path();
33568        std::fs::write(root.join("README.md"), "# stable\n").unwrap();
33569        let summaries_db_path = root.join(".tsift/summaries.db");
33570        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33571        let mut summary = summarize::Summary {
33572            id: 0,
33573            symbol_name: "main".to_string(),
33574            file_path: "src/main.rs".to_string(),
33575            content_hash: "hash-main".to_string(),
33576            summary: "main wires the CLI".to_string(),
33577            entities: Some(vec![summarize::Entity {
33578                name: "Cli".to_string(),
33579                kind: "type".to_string(),
33580                description: "Command-line interface".to_string(),
33581            }]),
33582            relationships: None,
33583            concept_labels: Some(vec!["cli".to_string()]),
33584            extracted_at: "1700000000".to_string(),
33585            model: "test-model".to_string(),
33586            tokens_input: Some(10),
33587            tokens_output: Some(5),
33588        };
33589        summary_db.insert(&summary).unwrap();
33590        drop(summary_db);
33591
33592        let hint = root.join("README.md");
33593        let first = traversal_source_watermark(root, &hint, None, true)
33594            .expect("first watermark call must succeed")
33595            .expect("first watermark must produce a hash");
33596
33597        std::thread::sleep(std::time::Duration::from_millis(20));
33598        let conn = Connection::open(&summaries_db_path).unwrap();
33599        conn.pragma_update(None, "user_version", 1).unwrap();
33600        conn.pragma_update(None, "user_version", 0).unwrap();
33601        drop(conn);
33602
33603        let second = traversal_source_watermark(root, &hint, None, true)
33604            .expect("second watermark call must succeed")
33605            .expect("second watermark must produce a hash");
33606        assert_eq!(
33607            first, second,
33608            "metadata-only summaries.db churn must not invalidate the source watermark"
33609        );
33610
33611        summary.entities = Some(vec![summarize::Entity {
33612            name: "GraphCache".to_string(),
33613            kind: "type".to_string(),
33614            description: "Stable full-projection cache input".to_string(),
33615        }]);
33616        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33617        summary_db.delete_by_file("src/main.rs").unwrap();
33618        summary_db.insert(&summary).unwrap();
33619        drop(summary_db);
33620
33621        let third = traversal_source_watermark(root, &hint, None, true)
33622            .expect("third watermark call must succeed")
33623            .expect("third watermark must produce a hash");
33624        assert_ne!(
33625            first, third,
33626            "semantic summary row changes must invalidate the source watermark"
33627        );
33628    }
33629
33630    #[test]
33631    fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
33632        // #gfullhot: backend-eval full-projection cache keys should be based on
33633        // the indexed graph inputs, not file_state mtimes. Touching a source file
33634        // without changing extracted symbols/call edges must still hit the cache.
33635        let dir = tempfile::tempdir().unwrap();
33636        let root = dir.path();
33637        std::fs::create_dir_all(root.join("src")).unwrap();
33638        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33639        let source = root.join("src/lib.rs");
33640        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33641        std::fs::write(&source, source_body).unwrap();
33642        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33643        db.rebuild(root).unwrap();
33644        drop(db);
33645
33646        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33647            .unwrap()
33648            .value;
33649        std::thread::sleep(std::time::Duration::from_millis(20));
33650        std::fs::write(&source, source_body).unwrap();
33651        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33652        db.apply_changes(root).unwrap();
33653        drop(db);
33654
33655        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33656            .unwrap()
33657            .value;
33658        assert_eq!(
33659            first, second,
33660            "mtime-only source index churn must not invalidate the full-projection cache"
33661        );
33662    }
33663
33664    #[test]
33665    fn full_projection_source_watermark_ignores_session_markdown_churn() {
33666        // #gfullhot: the full-projection performance cache isolates code graph
33667        // and semantic-summary inputs. Current session evidence is measured by
33668        // the bounded real dataset, so unrelated task-doc edits must not force a
33669        // million-row full-projection rebuild.
33670        let dir = tempfile::tempdir().unwrap();
33671        let root = dir.path();
33672        std::fs::create_dir_all(root.join("src")).unwrap();
33673        std::fs::create_dir_all(root.join("tasks/software")).unwrap();
33674        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33675        std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
33676        let task_doc = root.join("tasks/software/tsift.md");
33677        std::fs::write(
33678            &task_doc,
33679            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
33680        )
33681        .unwrap();
33682        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33683        db.rebuild(root).unwrap();
33684        drop(db);
33685
33686        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33687            .unwrap()
33688            .value;
33689        std::fs::write(
33690            &task_doc,
33691            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
33692        )
33693        .unwrap();
33694        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33695            .unwrap()
33696            .value;
33697        assert_eq!(
33698            first, second,
33699            "session markdown churn must not invalidate the full-projection code/summary cache"
33700        );
33701    }
33702
33703    #[test]
33704    fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
33705        // #gfullhot: once a full-project projection is cached, repeated samples
33706        // with unchanged graph inputs must report zero source_graph_build and
33707        // projection_rows work even if indexed file mtimes changed.
33708        let dir = tempfile::tempdir().unwrap();
33709        let root = dir.path();
33710        std::fs::create_dir_all(root.join("src")).unwrap();
33711        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33712        let source = root.join("src/lib.rs");
33713        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33714        std::fs::write(&source, source_body).unwrap();
33715        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33716        db.rebuild(root).unwrap();
33717        drop(db);
33718
33719        let (_projection, _warnings, _phases, first_stats) =
33720            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33721        assert!(
33722            !first_stats.hit,
33723            "the first full-projection run should populate the cache"
33724        );
33725
33726        std::thread::sleep(std::time::Duration::from_millis(20));
33727        std::fs::write(&source, source_body).unwrap();
33728        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33729        db.apply_changes(root).unwrap();
33730        drop(db);
33731
33732        let (_projection, _warnings, phases, second_stats) =
33733            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33734        assert!(second_stats.hit, "mtime-only churn should still cache-hit");
33735        let source_graph_build = phases
33736            .iter()
33737            .find(|phase| phase.name == "full_projection.source_graph_build")
33738            .expect("cache hit must report source_graph_build");
33739        let projection_rows = phases
33740            .iter()
33741            .find(|phase| phase.name == "full_projection.projection_rows")
33742            .expect("cache hit must report projection_rows");
33743        assert_eq!(source_graph_build.duration_micros, 0);
33744        assert_eq!(projection_rows.duration_micros, 0);
33745    }
33746
33747    #[test]
33748    fn build_token_capped_preview_within_cap() {
33749        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 2", "}"];
33750        let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
33751        assert!(!capped.was_capped);
33752        assert_eq!(capped.preview.len(), 3);
33753        assert_eq!(capped.capped_end, 3);
33754    }
33755
33756    #[test]
33757    fn build_token_capped_preview_truncates_long_body() {
33758        let owned: Vec<String> = (0..200)
33759            .map(|i| format!("    let line_{i} = {i};"))
33760            .collect();
33761        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33762        let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
33763        assert!(capped.was_capped);
33764        assert!(capped.preview.len() < 200);
33765        assert!(capped.capped_end < 200);
33766        assert!(!capped.preview.is_empty());
33767    }
33768
33769    #[test]
33770    fn build_token_capped_preview_respects_start_offset() {
33771        let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
33772        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33773        let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
33774        assert!(capped.was_capped);
33775        assert!(capped.capped_end >= 50);
33776        assert!(capped.capped_end < 100);
33777        assert_eq!(capped.preview[0].line, 50);
33778    }
33779
33780    #[test]
33781    fn response_budget_body_token_cap_defaults() {
33782        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
33783        assert_eq!(budget.body_token_cap(), 1500);
33784
33785        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
33786        assert_eq!(budget.body_token_cap(), 500);
33787
33788        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
33789        assert_eq!(budget.body_token_cap(), 3000);
33790    }
33791
33792    #[test]
33793    fn build_token_capped_preview_empty_input() {
33794        let lines: Vec<&str> = vec![];
33795        let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
33796        assert!(!capped.was_capped);
33797        assert!(capped.preview.is_empty());
33798    }
33799
33800    #[test]
33801    fn build_token_capped_preview_single_long_line_fits() {
33802        let lines: Vec<&str> = vec!["short"];
33803        let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
33804        assert!(!capped.was_capped);
33805        assert_eq!(capped.preview.len(), 1);
33806        assert_eq!(capped.capped_end, 1);
33807    }
33808
33809    #[test]
33810    fn edge_index_replaces_from_id_to_id_with_positions() {
33811        let input = serde_json::json!({
33812            "nodes": [
33813                {"id": "symbol:src/lib.rs:foo"},
33814                {"id": "symbol:src/lib.rs:bar"},
33815                {"id": "symbol:src/lib.rs:baz"}
33816            ],
33817            "edges": [
33818                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
33819                {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
33820            ]
33821        });
33822        let result = edge_index_transform(input);
33823        let edges = result.get("edges").unwrap().as_array().unwrap();
33824        assert_eq!(edges.len(), 2);
33825        assert_eq!(edges[0]["from"], 0);
33826        assert_eq!(edges[0]["to"], 1);
33827        assert_eq!(edges[1]["from"], 1);
33828        assert_eq!(edges[1]["to"], 2);
33829        assert!(edges[0].get("from_id").is_none());
33830        assert!(edges[0].get("to_id").is_none());
33831    }
33832
33833    #[test]
33834    fn edge_index_preserves_unresolved_ids_as_strings() {
33835        let input = serde_json::json!({
33836            "nodes": [{"id": "symbol:src/lib.rs:foo"}],
33837            "edges": [
33838                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
33839            ]
33840        });
33841        let result = edge_index_transform(input);
33842        let edge = &result["edges"][0];
33843        assert_eq!(edge["from"], 0);
33844        assert_eq!(edge["to_id"], "symbol:other.rs:missing");
33845    }
33846
33847    #[test]
33848    fn edge_index_noop_without_nodes_and_edges() {
33849        let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
33850        let result = edge_index_transform(input);
33851        assert_eq!(result["report"]["entries"][0]["from_id"], "a");
33852    }
33853}
33854
33855// --- SQL introspection ---
33856
33857#[derive(Serialize)]
33858struct TableInfo {
33859    name: String,
33860    columns: Vec<ColumnInfo>,
33861    row_count: i64,
33862}
33863
33864#[derive(Serialize)]
33865struct ColumnInfo {
33866    name: String,
33867    #[serde(rename = "type")]
33868    col_type: String,
33869    notnull: bool,
33870    pk: bool,
33871    #[serde(skip_serializing_if = "Option::is_none")]
33872    default_value: Option<String>,
33873}
33874
33875/// Open a SQLite connection (read-only).
33876pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
33877    let conn = Connection::open_with_flags(
33878        path,
33879        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
33880    )
33881    .with_context(|| format!("opening database: {}", path.display()))?;
33882    Ok(conn)
33883}
33884
33885/// List all user tables with column metadata and row counts.
33886pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
33887    let mut stmt = conn.prepare(
33888        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
33889    )?;
33890    let table_names: Vec<String> = stmt
33891        .query_map([], |row| row.get(0))?
33892        .collect::<std::result::Result<Vec<_>, _>>()?;
33893
33894    let mut tables = Vec::new();
33895    for tbl in table_names {
33896        let columns = table_columns(conn, &tbl)?;
33897        let row_count: i64 =
33898            conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
33899                row.get(0)
33900            })?;
33901        tables.push(TableInfo {
33902            name: tbl,
33903            columns,
33904            row_count,
33905        });
33906    }
33907    Ok(tables)
33908}
33909
33910/// Get column metadata for a single table.
33911pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
33912    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
33913    let cols = stmt
33914        .query_map([], |row| {
33915            Ok(ColumnInfo {
33916                name: row.get(1)?,
33917                col_type: row.get::<_, String>(2).unwrap_or_default(),
33918                notnull: row.get::<_, bool>(3).unwrap_or(false),
33919                pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
33920                default_value: row.get(4)?,
33921            })
33922        })?
33923        .collect::<std::result::Result<Vec<_>, _>>()?;
33924    Ok(cols)
33925}
33926
33927/// Execute an arbitrary SQL query and return rows as JSON values.
33928pub(crate) fn execute_query(
33929    conn: &Connection,
33930    sql: &str,
33931) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
33932    let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
33933    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
33934    let col_count = col_names.len();
33935
33936    let mut rows = Vec::new();
33937    let mut query_rows = stmt.query([])?;
33938    while let Some(row) = query_rows.next()? {
33939        let mut vals = Vec::with_capacity(col_count);
33940        for i in 0..col_count {
33941            let val = match row.get_ref(i)? {
33942                rusqlite::types::ValueRef::Null => serde_json::Value::Null,
33943                rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
33944                rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
33945                rusqlite::types::ValueRef::Text(s) => {
33946                    serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
33947                }
33948                rusqlite::types::ValueRef::Blob(b) => {
33949                    serde_json::Value::String(format!("<blob {} bytes>", b.len()))
33950                }
33951            };
33952            vals.push(val);
33953        }
33954        rows.push(vals);
33955    }
33956    Ok((col_names, rows))
33957}
33958
33959#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33960enum DigestRunnerKind {
33961    Test,
33962    Log,
33963}
33964
33965impl DigestRunnerKind {
33966    fn parse(raw: &str) -> Result<Self> {
33967        match raw.trim().to_ascii_lowercase().as_str() {
33968            "test" => Ok(Self::Test),
33969            "log" => Ok(Self::Log),
33970            other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
33971        }
33972    }
33973
33974    fn as_str(self) -> &'static str {
33975        match self {
33976            Self::Test => "test",
33977            Self::Log => "log",
33978        }
33979    }
33980}
33981
33982/// Simple shell word splitting (handles single and double quotes).
33983pub(crate) fn shell_split(s: &str) -> Vec<&str> {
33984    let mut parts = Vec::new();
33985    let mut i = 0;
33986    let bytes = s.as_bytes();
33987    while i < bytes.len() {
33988        // Skip whitespace
33989        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
33990            i += 1;
33991        }
33992        if i >= bytes.len() {
33993            break;
33994        }
33995        let start = i;
33996        if bytes[i] == b'"' || bytes[i] == b'\'' {
33997            let quote = bytes[i];
33998            i += 1;
33999            while i < bytes.len() && bytes[i] != quote {
34000                i += 1;
34001            }
34002            if i < bytes.len() {
34003                i += 1; // closing quote
34004            }
34005        } else {
34006            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
34007                i += 1;
34008            }
34009        }
34010        parts.push(&s[start..i]);
34011    }
34012    parts
34013}
34014
34015/// Quote a string for shell if it contains special characters.
34016pub(crate) fn shell_quote(s: &str) -> String {
34017    // Strip existing quotes
34018    let unquoted =
34019        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
34020            &s[1..s.len() - 1]
34021        } else {
34022            s
34023        };
34024
34025    if unquoted
34026        .chars()
34027        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
34028    {
34029        format!("\"{}\"", unquoted)
34030    } else {
34031        format!(
34032            "\"{}\"",
34033            unquoted.replace('\\', "\\\\").replace('"', "\\\"")
34034        )
34035    }
34036}
34037
34038fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
34039    sift::SearchCoverageSnapshot {
34040        mode: sift::SearchCoverageMode::Sealed,
34041        total_sector_count: 0,
34042        mounted_sector_count: 0,
34043        reused_sector_count: 0,
34044        dirty_sector_count: 0,
34045        completed_dirty_sector_count: 0,
34046        rebuilding_sector_count: 0,
34047        resumed_sector_count: 0,
34048        active_rebuild: None,
34049    }
34050}
34051
34052fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
34053    let total_sector_count = responses
34054        .iter()
34055        .map(|response| response.coverage.total_sector_count)
34056        .sum();
34057    let mounted_sector_count = responses
34058        .iter()
34059        .map(|response| response.coverage.mounted_sector_count)
34060        .sum();
34061    let reused_sector_count = responses
34062        .iter()
34063        .map(|response| response.coverage.reused_sector_count)
34064        .sum();
34065    let dirty_sector_count = responses
34066        .iter()
34067        .map(|response| response.coverage.dirty_sector_count)
34068        .sum();
34069    let completed_dirty_sector_count = responses
34070        .iter()
34071        .map(|response| response.coverage.completed_dirty_sector_count)
34072        .sum();
34073    let rebuilding_sector_count = responses
34074        .iter()
34075        .map(|response| response.coverage.rebuilding_sector_count)
34076        .sum();
34077    let resumed_sector_count = responses
34078        .iter()
34079        .map(|response| response.coverage.resumed_sector_count)
34080        .sum();
34081
34082    let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
34083        sift::SearchCoverageMode::Sealed
34084    } else if completed_dirty_sector_count > 0
34085        || rebuilding_sector_count > 0
34086        || resumed_sector_count > 0
34087    {
34088        sift::SearchCoverageMode::Converging
34089    } else {
34090        sift::SearchCoverageMode::Frontier
34091    };
34092
34093    sift::SearchCoverageSnapshot {
34094        mode,
34095        total_sector_count,
34096        mounted_sector_count,
34097        reused_sector_count,
34098        dirty_sector_count,
34099        completed_dirty_sector_count,
34100        rebuilding_sector_count,
34101        resumed_sector_count,
34102        active_rebuild: responses
34103            .iter()
34104            .find_map(|response| response.coverage.active_rebuild.clone()),
34105    }
34106}
34107
34108fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
34109    sift::SearchResponse {
34110        strategy: strategy.to_string(),
34111        root: root.display().to_string(),
34112        indexed_artifacts: 0,
34113        skipped_artifacts: 0,
34114        coverage: empty_search_coverage(),
34115        hits: Vec::new(),
34116    }
34117}
34118
34119fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
34120    for hit in &mut response.hits {
34121        let path = Path::new(&hit.path);
34122        if path.is_relative() {
34123            hit.path = search_root.join(path).display().to_string();
34124        }
34125    }
34126}
34127
34128fn merge_search_responses(
34129    root: &Path,
34130    strategy: &str,
34131    limit: usize,
34132    responses: Vec<sift::SearchResponse>,
34133) -> sift::SearchResponse {
34134    let indexed_artifacts = responses
34135        .iter()
34136        .map(|response| response.indexed_artifacts)
34137        .sum();
34138    let skipped_artifacts = responses
34139        .iter()
34140        .map(|response| response.skipped_artifacts)
34141        .sum();
34142    let coverage = if responses.is_empty() {
34143        empty_search_coverage()
34144    } else {
34145        aggregate_search_coverage(&responses)
34146    };
34147    let mut hits: Vec<sift::SearchHit> = responses
34148        .into_iter()
34149        .flat_map(|response| response.hits)
34150        .collect();
34151    hits.sort_by(|left, right| {
34152        right
34153            .score
34154            .partial_cmp(&left.score)
34155            .unwrap_or(Ordering::Equal)
34156            .then_with(|| left.path.cmp(&right.path))
34157            .then_with(|| left.location.cmp(&right.location))
34158    });
34159    hits.truncate(limit);
34160    for (rank, hit) in hits.iter_mut().enumerate() {
34161        hit.rank = rank + 1;
34162    }
34163
34164    sift::SearchResponse {
34165        strategy: strategy.to_string(),
34166        root: root.display().to_string(),
34167        indexed_artifacts,
34168        skipped_artifacts,
34169        coverage,
34170        hits,
34171    }
34172}
34173
34174pub(crate) fn federated_sift_search(
34175    root: &Path,
34176    cache_dir: &Path,
34177    query: &str,
34178    limit: usize,
34179    timeout_secs: u64,
34180    strategy: &str,
34181    fts_index_fresh: Option<bool>,
34182) -> Result<sift::SearchResponse> {
34183    let targets = resolve_search_index_targets(root, root, None, true)?;
34184    if targets.is_empty() {
34185        if config::Config::submodule_dirs(root)?.is_empty() {
34186            return run_search_with_timeout(
34187                root,
34188                cache_dir,
34189                query,
34190                limit,
34191                timeout_secs,
34192                strategy,
34193                &[],
34194                fts_index_fresh,
34195            );
34196        }
34197        return Ok(empty_search_response(root, strategy));
34198    }
34199
34200    let mut responses = Vec::with_capacity(targets.len());
34201    for target in &targets {
34202        let mut response = run_search_with_timeout(
34203            &target.source_root,
34204            cache_dir,
34205            query,
34206            limit,
34207            timeout_secs,
34208            strategy,
34209            std::slice::from_ref(target),
34210            fts_index_fresh,
34211        )?;
34212        absolutize_search_hit_paths(&mut response, &target.source_root);
34213        response.root = root.display().to_string();
34214        responses.push(response);
34215    }
34216
34217    Ok(merge_search_responses(root, strategy, limit, responses))
34218}
34219
34220/// Federated symbol search across every scoped `.tsift/indexes/<scope>/index.db`
34221/// in the workspace. Per-scope tagpath annotation runs inside the per-scope
34222/// loop so each scope's adapter resolves against its own `.naming.toml` /
34223/// `.naming/index.json` (the workspace root usually has no tagpath of its
34224/// own). The merged `TagpathAnnotationDiagnostic` reports `loaded=true` when
34225/// at least one scope loaded, and `stale=true` with the first stale reason
34226/// when any scope was stale.
34227pub(crate) fn federated_symbol_search(
34228    root: &std::path::Path,
34229    query: &str,
34230    limit: usize,
34231    tagpath_opts: &TagpathSearchOpts,
34232) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
34233    let cfg = config::Config::load(root)?;
34234    let submodules = config::Config::submodule_dirs(root)?;
34235    let mut all_hits: Vec<index::SymbolHit> = Vec::new();
34236    let mut combined = TagpathAnnotationDiagnostic::default();
34237    for scope in &submodules {
34238        if !cfg.federation_for_scope(scope) {
34239            continue;
34240        }
34241        let db_path = cfg.db_path_for(root, &scope.id);
34242        if !db_path.exists() {
34243            continue;
34244        }
34245        let db = index::IndexDb::open_read_only(&db_path)?;
34246        let mut hits = db.symbol_search(query, limit)?;
34247        let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
34248        combined.loaded |= diag.loaded;
34249        if diag.stale && !combined.stale {
34250            combined.stale = true;
34251            combined.reason = diag.reason;
34252        }
34253        all_hits.append(&mut hits);
34254    }
34255    all_hits.sort_by(|a, b| {
34256        b.score
34257            .partial_cmp(&a.score)
34258            .unwrap_or(std::cmp::Ordering::Equal)
34259    });
34260    all_hits.truncate(limit);
34261    Ok((all_hits, combined))
34262}
34263
34264#[derive(Debug, Deserialize)]
34265#[serde(tag = "type", rename_all = "lowercase")]
34266enum RipgrepJsonEvent {
34267    Match {
34268        data: RipgrepMatchData,
34269    },
34270    #[serde(other)]
34271    Other,
34272}
34273
34274#[derive(Debug, Deserialize)]
34275struct RipgrepMatchData {
34276    path: RipgrepTextField,
34277    lines: RipgrepTextField,
34278    line_number: Option<usize>,
34279}
34280
34281#[derive(Debug, Deserialize)]
34282struct RipgrepTextField {
34283    text: Option<String>,
34284}
34285
34286pub(crate) fn federated_exact_search(
34287    root: &Path,
34288    query: &str,
34289    limit: usize,
34290    timeout_secs: u64,
34291) -> Result<sift::SearchResponse> {
34292    let cfg = config::Config::load(root)?;
34293    let mut responses = Vec::new();
34294    for scope in config::Config::submodule_dirs(root)? {
34295        if !cfg.federation_for_scope(&scope) {
34296            continue;
34297        }
34298        let mut response =
34299            run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
34300        absolutize_search_hit_paths(&mut response, &scope.source_root);
34301        response.root = root.display().to_string();
34302        responses.push(response);
34303    }
34304
34305    Ok(merge_search_responses(root, "exact", limit, responses))
34306}
34307
34308pub(crate) fn run_sift_search(
34309    search_path: &Path,
34310    cache_dir: &Path,
34311    query: &str,
34312    limit: usize,
34313    strategy: &str,
34314    // #015t Phase 4b — the caller's already-known FTS index freshness:
34315    //   `Some(true)`  — caller (cmd_search after precheck+autoindex) proved fresh;
34316    //                   use FTS without re-walking the tree.
34317    //   `Some(false)` — caller proved stale/degraded (e.g. read-only writer lock);
34318    //                   skip FTS, serve live results via the TokenIndex fallback.
34319    //   `None`        — unknown (direct programmatic callers); inspect here.
34320    // This drops the redundant `inspect_read_only` walk on the normal CLI path,
34321    // where `precheck_search_indexes` already established freshness.
34322    fts_index_fresh: Option<bool>,
34323) -> Result<sift::SearchResponse> {
34324    // #015t Phase 4 cutover: the `index.db` FTS5 path is now the DEFAULT for
34325    // lexical search. The normal search flow runs `precheck_search_indexes` with
34326    // autoindex first, so a fresh root `index.db` is guaranteed present before we
34327    // get here; the FTS5 BM25 path supersedes the parallel JSON `TokenIndex`
34328    // (which never rebuilt on content change — staleness was keyed on file
34329    // existence only). Ranking shifts from substring-position to BM25 by design;
34330    // the Phase 3 soundness gate proved candidate coverage (FTS ⊇ TokenIndex).
34331    //
34332    // The JSON `TokenIndex` is demoted to a FALLBACK for the only remaining cases
34333    // that reach here without a fresh root index.db: an un-indexed root reached
34334    // with `--no-autoindex` (the normal precheck degrades a missing index to exact
34335    // search, not lexical), a stale/degraded index (live results via TokenIndex),
34336    // and direct programmatic callers. `TSIFT_FTS_SEARCH=0` (`0`/`false`/`no`/`off`)
34337    // forces that legacy path as a transition escape hatch. Both the in-process
34338    // (timeout=0) and `__search-worker` subprocess routes pass through here; the
34339    // worker inherits the env var and is handed the freshness verdict explicitly.
34340    if !fts_search_forced_off() {
34341        let db_path = search_path.join(".tsift/index.db");
34342        let use_fts = match fts_index_fresh {
34343            Some(fresh) => fresh && db_path.exists(),
34344            None => db_path.exists() && index_db_is_fresh_for_fts(&db_path, search_path),
34345        };
34346        if use_fts {
34347            return sift::fts_search(&db_path, search_path, query, limit)
34348                .context("index.db FTS5 search failed");
34349        }
34350    }
34351
34352    let engine = Sift::builder().with_cache_dir(cache_dir).build();
34353    let options = SearchOptions::default()
34354        .with_limit(limit)
34355        .with_strategy(strategy.to_string());
34356    let input = SearchInput::new(search_path, query).with_options(options);
34357    engine.search(input).context("sift search failed")
34358}
34359
34360/// #015t Phase 4 — the FTS5 `index.db` path is trustworthy only when the index is
34361/// **openable AND fresh** for the search root. A missing/corrupt index.db (e.g. an
34362/// empty placeholder) or a **stale** one (e.g. held open by a concurrent writer so
34363/// autoindex degraded to read-only) falls back to the live `TokenIndex` path — so a
34364/// search never returns content the index has not caught up to. The normal flow's
34365/// `precheck_search_indexes` + autoindex makes this true in the common case;
34366/// re-inspecting here is a redundant tree-walk that #015t Phase 4b can replace by
34367/// threading the precheck's freshness result through to this call.
34368fn index_db_is_fresh_for_fts(db_path: &Path, search_path: &Path) -> bool {
34369    match index::IndexDb::inspect_read_only(db_path, search_path, false) {
34370        Ok(inspection) => {
34371            inspection.summary.new + inspection.summary.modified + inspection.summary.deleted == 0
34372        }
34373        Err(_) => false,
34374    }
34375}
34376
34377/// #015t Phase 4 — whether the operator has forced lexical search back onto the
34378/// legacy JSON `TokenIndex` path via `TSIFT_FTS_SEARCH` set to a falsy value
34379/// (`0`/`false`/`no`/`off`). The FTS5 `index.db` path is the default; this is the
34380/// transition escape hatch. Any other value (or unset) keeps the FTS5 default.
34381fn fts_search_forced_off() -> bool {
34382    std::env::var("TSIFT_FTS_SEARCH")
34383        .map(|value| fts_flag_value_disabled(&value))
34384        .unwrap_or(false)
34385}
34386
34387/// Pure parser for a falsy `TSIFT_FTS_SEARCH` value (factored out so the rules
34388/// can be unit-tested without mutating process env).
34389fn fts_flag_value_disabled(value: &str) -> bool {
34390    matches!(
34391        value.trim().to_ascii_lowercase().as_str(),
34392        "0" | "false" | "no" | "off"
34393    )
34394}
34395
34396fn exact_search_timeout_message(timeout_secs: u64) -> String {
34397    format!(
34398        "tsift search timed out after {}s (strategy: exact). \
34399         Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
34400        timeout_secs
34401    )
34402}
34403
34404fn exact_search_command(search_path: &Path, query: &str) -> Command {
34405    let mut command = Command::new("rg");
34406    command
34407        .arg("--json")
34408        .arg("--fixed-strings")
34409        .arg("--line-number")
34410        .arg("--hidden")
34411        .arg("--")
34412        .arg(query)
34413        .arg(search_path);
34414    command
34415}
34416
34417fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
34418    let observed_unix_secs = SystemTime::now()
34419        .duration_since(UNIX_EPOCH)
34420        .unwrap_or_default()
34421        .as_secs() as i64;
34422    let modified_unix_secs = fs::metadata(path)
34423        .ok()
34424        .and_then(|metadata| metadata.modified().ok())
34425        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
34426        .map(|duration| duration.as_secs() as i64);
34427    sift::ArtifactFreshness {
34428        observed_unix_secs,
34429        modified_unix_secs,
34430    }
34431}
34432
34433fn parse_exact_search_output(
34434    search_path: &Path,
34435    limit: usize,
34436    raw: &str,
34437) -> Result<sift::SearchResponse> {
34438    if limit == 0 {
34439        return Ok(sift::SearchResponse {
34440            strategy: "exact".to_string(),
34441            root: search_path.display().to_string(),
34442            indexed_artifacts: 0,
34443            skipped_artifacts: 0,
34444            coverage: empty_search_coverage(),
34445            hits: Vec::new(),
34446        });
34447    }
34448
34449    let mut hits = Vec::new();
34450    for line in raw.lines() {
34451        let event: RipgrepJsonEvent =
34452            serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
34453        let RipgrepJsonEvent::Match { data } = event else {
34454            continue;
34455        };
34456        let Some(path_text) = data.path.text else {
34457            continue;
34458        };
34459        let Some(lines_text) = data.lines.text else {
34460            continue;
34461        };
34462        let path = PathBuf::from(path_text);
34463        let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
34464        let rank = hits.len() + 1;
34465        hits.push(sift::SearchHit {
34466            artifact_id: format!(
34467                "exact:{}:{}:{}",
34468                path.display(),
34469                data.line_number.unwrap_or(0),
34470                rank
34471            ),
34472            artifact_kind: sift::ContextArtifactKind::File,
34473            path: path.display().to_string(),
34474            rank,
34475            score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
34476            confidence: sift::ScoreConfidence::High,
34477            location: data.line_number.map(|line| format!("line {}", line)),
34478            snippet: snippet.clone(),
34479            provenance: sift::ArtifactProvenance {
34480                adapter: sift::AcquisitionAdapterKind::FileSystem,
34481                source: "ripgrep -F".to_string(),
34482                synthetic: false,
34483            },
34484            freshness: exact_search_file_timestamp(&path),
34485            budget: sift::ArtifactBudget::from_text(&snippet, 1),
34486        });
34487        if hits.len() >= limit {
34488            break;
34489        }
34490    }
34491
34492    Ok(sift::SearchResponse {
34493        strategy: "exact".to_string(),
34494        root: search_path.display().to_string(),
34495        indexed_artifacts: hits.len(),
34496        skipped_artifacts: 0,
34497        coverage: empty_search_coverage(),
34498        hits,
34499    })
34500}
34501
34502fn exact_search_response_from_process(
34503    search_path: &Path,
34504    limit: usize,
34505    status: std::process::ExitStatus,
34506    stdout: &[u8],
34507    stderr: &[u8],
34508) -> Result<sift::SearchResponse> {
34509    if !status.success() && status.code() != Some(1) {
34510        let message = String::from_utf8_lossy(stderr);
34511        let trimmed = message.trim();
34512        if trimmed.is_empty() {
34513            bail!("ripgrep exact search exited with status {}", status);
34514        }
34515        bail!("{}", trimmed);
34516    }
34517
34518    let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
34519    parse_exact_search_output(search_path, limit, &raw)
34520}
34521
34522fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
34523    let output = exact_search_command(search_path, query)
34524        .output()
34525        .context("running exact search with ripgrep")?;
34526    exact_search_response_from_process(
34527        search_path,
34528        limit,
34529        output.status,
34530        &output.stdout,
34531        &output.stderr,
34532    )
34533}
34534
34535pub(crate) fn run_exact_search_with_timeout(
34536    search_path: &Path,
34537    query: &str,
34538    limit: usize,
34539    timeout_secs: u64,
34540) -> Result<sift::SearchResponse> {
34541    if timeout_secs == 0 {
34542        return run_exact_search(search_path, query, limit);
34543    }
34544
34545    let mut child = exact_search_command(search_path, query)
34546        .stdin(Stdio::null())
34547        .stdout(Stdio::piped())
34548        .stderr(Stdio::piped())
34549        .spawn()
34550        .context("spawning timed exact search worker")?;
34551
34552    let timeout = Duration::from_secs(timeout_secs);
34553    let status = wait_for_child_exit(&mut child, timeout)
34554        .context("waiting for timed exact search worker")?;
34555    if status.is_none() {
34556        let _ = child.kill();
34557        let _ = child.wait();
34558        bail!("{}", exact_search_timeout_message(timeout_secs));
34559    }
34560
34561    let status = status.unwrap();
34562    let stdout = read_child_stdout(&mut child)?;
34563    let stderr = read_child_stderr(&mut child)?;
34564    exact_search_response_from_process(
34565        search_path,
34566        limit,
34567        status,
34568        stdout.as_bytes(),
34569        stderr.as_bytes(),
34570    )
34571}
34572
34573#[allow(clippy::too_many_arguments)]
34574pub(crate) fn run_search_with_timeout(
34575    search_path: &Path,
34576    cache_dir: &Path,
34577    query: &str,
34578    limit: usize,
34579    timeout_secs: u64,
34580    strategy: &str,
34581    search_targets: &[SearchIndexTarget],
34582    // #015t Phase 4b — FTS index freshness verdict forwarded to the worker so it
34583    // skips the redundant `inspect_read_only` walk (see `run_sift_search`).
34584    fts_index_fresh: Option<bool>,
34585) -> Result<sift::SearchResponse> {
34586    if timeout_secs == 0 {
34587        return run_sift_search(search_path, cache_dir, query, limit, strategy, fts_index_fresh);
34588    }
34589
34590    let output_path = next_search_worker_output_path();
34591    let mut command = Command::new(
34592        std::env::current_exe().context("resolving tsift executable for timed search")?,
34593    );
34594    command
34595        .arg("__search-worker")
34596        .arg("--path")
34597        .arg(search_path)
34598        .arg("--cache-dir")
34599        .arg(cache_dir)
34600        .arg("--query")
34601        .arg(query)
34602        .arg("--limit")
34603        .arg(limit.to_string())
34604        .arg("--strategy")
34605        .arg(strategy)
34606        .arg("--output")
34607        .arg(&output_path);
34608    if let Some(fresh) = fts_index_fresh {
34609        command.arg("--fts-index-fresh").arg(fresh.to_string());
34610    }
34611    let mut child = command
34612        .stdin(Stdio::null())
34613        .stdout(Stdio::null())
34614        .stderr(Stdio::piped())
34615        .spawn()
34616        .context("spawning timed sift search worker")?;
34617
34618    let timeout = Duration::from_secs(timeout_secs);
34619    let status =
34620        wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
34621    if status.is_none() {
34622        let _ = child.kill();
34623        let _ = child.wait();
34624        let _ = fs::remove_file(&output_path);
34625        bail!(
34626            "{}",
34627            search_timeout_message(timeout_secs, strategy, search_targets)?
34628        );
34629    }
34630
34631    let status = status.unwrap();
34632    let stderr = read_child_stderr(&mut child)?;
34633    if !status.success() {
34634        let _ = fs::remove_file(&output_path);
34635        let message = stderr.trim();
34636        if message.is_empty() {
34637            bail!("sift search worker exited with status {}", status);
34638        }
34639        bail!("{}", message);
34640    }
34641
34642    let raw = fs::read_to_string(&output_path)
34643        .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
34644    let _ = fs::remove_file(&output_path);
34645    serde_json::from_str(&raw).context("parsing search worker output")
34646}
34647
34648fn next_search_worker_output_path() -> PathBuf {
34649    let stamp = SystemTime::now()
34650        .duration_since(UNIX_EPOCH)
34651        .unwrap_or_default()
34652        .as_nanos();
34653    std::env::temp_dir().join(format!(
34654        "tsift-search-{}-{}.json",
34655        std::process::id(),
34656        stamp
34657    ))
34658}
34659
34660fn wait_for_child_exit(
34661    child: &mut std::process::Child,
34662    timeout: Duration,
34663) -> Result<Option<std::process::ExitStatus>> {
34664    let started = Instant::now();
34665    loop {
34666        if let Some(status) = child.try_wait()? {
34667            return Ok(Some(status));
34668        }
34669        if started.elapsed() >= timeout {
34670            return Ok(None);
34671        }
34672        let remaining = timeout.saturating_sub(started.elapsed());
34673        std::thread::sleep(remaining.min(Duration::from_millis(10)));
34674    }
34675}
34676
34677fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
34678    let mut stderr = String::new();
34679    if let Some(mut pipe) = child.stderr.take() {
34680        pipe.read_to_string(&mut stderr)
34681            .context("reading search worker stderr")?;
34682    }
34683    Ok(stderr)
34684}
34685
34686fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
34687    let mut stdout = String::new();
34688    if let Some(mut pipe) = child.stdout.take() {
34689        pipe.read_to_string(&mut stdout)
34690            .context("reading search worker stdout")?;
34691    }
34692    Ok(stdout)
34693}
34694
34695pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
34696    if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
34697        fs::write(&path, std::process::id().to_string())
34698            .with_context(|| format!("writing search worker pid file: {path}"))?;
34699    }
34700    if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
34701        let delay_ms = ms
34702            .parse::<u64>()
34703            .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
34704        std::thread::sleep(Duration::from_millis(delay_ms));
34705    }
34706    Ok(())
34707}
34708
34709#[cfg(test)]
34710thread_local! {
34711    static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
34712}
34713
34714#[cfg(test)]
34715enum SearchPostPrecheckLockMode {
34716    RollbackJournal,
34717    Wal,
34718}
34719
34720#[cfg(test)]
34721struct SearchPostPrecheckLockHook {
34722    db_path: PathBuf,
34723    mode: SearchPostPrecheckLockMode,
34724}
34725
34726#[cfg(test)]
34727struct SearchPostPrecheckLockGuard;
34728
34729#[cfg(test)]
34730impl Drop for SearchPostPrecheckLockGuard {
34731    fn drop(&mut self) {
34732        SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34733            hook.borrow_mut().take();
34734        });
34735    }
34736}
34737
34738#[cfg(test)]
34739fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34740    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
34741}
34742
34743#[cfg(test)]
34744fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34745    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
34746}
34747
34748#[cfg(test)]
34749fn install_search_post_precheck_lock_hook(
34750    db_path: PathBuf,
34751    mode: SearchPostPrecheckLockMode,
34752) -> SearchPostPrecheckLockGuard {
34753    SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34754        assert!(
34755            hook.borrow().is_none(),
34756            "search post-precheck lock hook already installed"
34757        );
34758        *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
34759    });
34760    SearchPostPrecheckLockGuard
34761}
34762
34763#[cfg(test)]
34764pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34765    let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
34766        return Ok(());
34767    };
34768    let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
34769    std::thread::spawn(move || {
34770        let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
34771        match hook.mode {
34772            SearchPostPrecheckLockMode::RollbackJournal => {
34773                conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
34774                    .expect("acquiring rollback-journal hook lock");
34775                fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
34776                    .expect("writing rollback journal marker");
34777            }
34778            SearchPostPrecheckLockMode::Wal => {
34779                conn.execute_batch(
34780                    "PRAGMA journal_mode=WAL;
34781                     PRAGMA wal_autocheckpoint=0;
34782                     CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
34783                     INSERT INTO search_wal_lock_probe DEFAULT VALUES;
34784                     PRAGMA locking_mode=EXCLUSIVE;
34785                     BEGIN EXCLUSIVE;",
34786                )
34787                .expect("acquiring WAL hook lock");
34788                assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
34789            }
34790        }
34791        ready_tx.send(()).expect("signaling search lock hook");
34792        std::thread::sleep(Duration::from_millis(200));
34793        drop(conn);
34794        let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
34795    });
34796    ready_rx
34797        .recv_timeout(Duration::from_secs(1))
34798        .context("waiting for search post-precheck lock hook")?;
34799    Ok(())
34800}
34801
34802#[cfg(not(test))]
34803pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34804    Ok(())
34805}